From 11e10616c3e5ff8339c6ea17fc2da82a1256ff7e Mon Sep 17 00:00:00 2001 From: Michael Vogt Date: Wed, 24 Feb 2021 08:23:51 +0000 Subject: [PATCH 1/1] Import snapd_2.49.orig.tar.gz [dgit import orig snapd_2.49.orig.tar.gz] --- .clang-format | 3 + .github/spread-problem-matcher.json | 14 + .github/workflows/cla-check.yaml | 28 + .github/workflows/macos-sanity.yaml | 48 + .github/workflows/test.yaml | 314 + .gitignore | 88 + .mailmap | 2 + CODE_OF_CONDUCT.md | 76 + CONTRIBUTING.md | 27 + COPYING | 674 + HACKING.md | 300 + PULL_REQUEST_TEMPLATE.md | 2 + README.md | 75 + advisor/backend.go | 300 + 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 + asserts/account.go | 117 + asserts/account_key.go | 288 + asserts/account_key_test.go | 809 ++ asserts/account_test.go | 195 + asserts/asserts.go | 1102 ++ asserts/asserts_test.go | 975 ++ asserts/assertstest/assertstest.go | 610 + asserts/assertstest/assertstest_test.go | 225 + asserts/batch.go | 229 + asserts/batch_test.go | 475 + asserts/crypto.go | 398 + asserts/database.go | 756 ++ asserts/database_test.go | 1400 ++ asserts/digest.go | 43 + asserts/digest_test.go | 65 + asserts/export_test.go | 294 + asserts/fetcher.go | 121 + asserts/fetcher_test.go | 167 + asserts/findwildcard.go | 187 + asserts/findwildcard_test.go | 282 + asserts/fsbackstore.go | 275 + asserts/fsbackstore_test.go | 372 + asserts/fsentryutils.go | 70 + asserts/fskeypairmgr.go | 92 + asserts/fskeypairmgr_test.go | 65 + asserts/gpgkeypairmgr.go | 353 + asserts/gpgkeypairmgr_test.go | 331 + asserts/header_checks.go | 312 + asserts/headers.go | 318 + asserts/headers_test.go | 396 + asserts/ifacedecls.go | 1447 ++ asserts/ifacedecls_test.go | 2339 ++++ asserts/internal/grouping.go | 286 + asserts/internal/grouping_test.go | 713 + asserts/membackstore.go | 290 + asserts/membackstore_test.go | 539 + asserts/memkeypairmgr.go | 59 + asserts/memkeypairmgr_test.go | 73 + asserts/model.go | 857 ++ asserts/model_test.go | 942 ++ asserts/pool.go | 775 ++ asserts/pool_test.go | 1019 ++ asserts/privkeys_for_test.go | 54 + asserts/repair.go | 218 + asserts/repair_test.go | 367 + asserts/serial_asserts.go | 250 + asserts/serial_asserts_test.go | 365 + asserts/signtool/sign.go | 110 + asserts/signtool/sign_test.go | 262 + asserts/snap_asserts.go | 949 ++ asserts/snap_asserts_test.go | 1919 +++ asserts/snapasserts/export_test.go | 21 + asserts/snapasserts/snapasserts.go | 158 + asserts/snapasserts/snapasserts_test.go | 334 + asserts/snapasserts/validation_sets.go | 452 + asserts/snapasserts/validation_sets_test.go | 654 + asserts/store_asserts.go | 162 + asserts/store_asserts_test.go | 235 + asserts/sysdb/generic.go | 196 + asserts/sysdb/staging.go | 183 + asserts/sysdb/sysdb.go | 56 + asserts/sysdb/sysdb_test.go | 216 + asserts/sysdb/testkeys.go | 30 + asserts/sysdb/trusted.go | 156 + asserts/system_user.go | 313 + asserts/system_user_test.go | 268 + asserts/systestkeys/trusted.go | 263 + asserts/validation_set.go | 266 + asserts/validation_set_test.go | 187 + boot/assets.go | 859 ++ boot/assets_test.go | 2765 ++++ boot/boot.go | 397 + boot/boot_robustness_test.go | 324 + boot/boot_test.go | 2843 ++++ boot/bootchain.go | 331 + boot/bootchain_test.go | 1236 ++ boot/booted_kernel_partition_linux.go | 56 + boot/booted_kernel_partition_test.go | 60 + boot/bootstate16.go | 197 + boot/bootstate20.go | 756 ++ boot/bootstate20_bloader_kernel_state.go | 295 + boot/boottest/bootenv.go | 169 + boot/boottest/device.go | 96 + boot/boottest/device_test.go | 111 + boot/boottest/model.go | 70 + boot/cmdline.go | 244 + boot/cmdline_test.go | 215 + boot/debug.go | 85 + boot/errors.go | 49 + boot/export_test.go | 191 + boot/initramfs.go | 121 + boot/initramfs20dirs.go | 105 + boot/initramfs_test.go | 632 + boot/kernel_os.go | 83 + boot/kernel_os_test.go | 693 + boot/makebootable.go | 398 + boot/makebootable_test.go | 1023 ++ boot/modeenv.go | 427 + boot/modeenv_test.go | 725 + boot/seal.go | 669 + boot/seal_test.go | 1048 ++ 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 | 129 + bootloader/assets/assets.go | 137 + bootloader/assets/assets_test.go | 156 + bootloader/assets/data/README.grub | 10 + bootloader/assets/data/grub-recovery.cfg | 63 + bootloader/assets/data/grub.cfg | 44 + bootloader/assets/export_test.go | 36 + bootloader/assets/genasset/export_test.go | 32 + bootloader/assets/genasset/main.go | 157 + bootloader/assets/genasset/main_test.go | 176 + bootloader/assets/generate.go | 23 + bootloader/assets/grub.go | 29 + bootloader/assets/grub_cfg_asset.go | 110 + bootloader/assets/grub_recovery_cfg_asset.go | 157 + bootloader/assets/grub_test.go | 128 + bootloader/bootloader.go | 409 + bootloader/bootloader_test.go | 362 + bootloader/bootloadertest/bootloadertest.go | 458 + bootloader/bootloadertest/utf16.go | 37 + bootloader/efi/efi.go | 184 + bootloader/efi/efi_test.go | 174 + bootloader/export_test.go | 226 + bootloader/grub.go | 505 + bootloader/grub_test.go | 1096 ++ bootloader/grubenv/grubenv.go | 117 + bootloader/grubenv/grubenv_test.go | 92 + bootloader/lk.go | 515 + bootloader/lk_test.go | 500 + bootloader/lkenv/export_test.go | 25 + bootloader/lkenv/lkenv.go | 673 + bootloader/lkenv/lkenv_test.go | 911 ++ bootloader/lkenv/lkenv_v1.go | 218 + bootloader/lkenv/lkenv_v2.go | 334 + bootloader/uboot.go | 199 + bootloader/uboot_test.go | 272 + bootloader/ubootenv/env.go | 299 + bootloader/ubootenv/env_test.go | 308 + bootloader/ubootenv/export_test.go | 24 + build-aux/snap/snapcraft.yaml | 129 + check-pr-title.py | 78 + client/aliases.go | 102 + client/aliases_test.go | 200 + client/apps.go | 264 + client/apps_test.go | 404 + client/asserts.go | 152 + client/asserts_test.go | 239 + client/buy.go | 63 + client/change.go | 164 + client/change_test.go | 233 + client/client.go | 749 ++ client/client_test.go | 643 + client/clientutil/snapinfo.go | 149 + client/clientutil/snapinfo_test.go | 286 + client/cohort.go | 50 + client/cohort_test.go | 77 + 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 | 148 + client/export_test.go | 55 + client/icons.go | 72 + client/icons_test.go | 74 + client/interfaces.go | 149 + client/interfaces_test.go | 268 + client/login.go | 155 + client/login_test.go | 164 + client/model.go | 104 + client/model_test.go | 176 + client/packages.go | 270 + client/packages_test.go | 407 + client/snap_op.go | 399 + client/snap_op_test.go | 569 + client/snapctl.go | 92 + client/snapctl_test.go | 84 + client/snapshot.go | 274 + client/snapshot_test.go | 299 + client/systems.go | 140 + client/systems_test.go | 217 + client/users.go | 145 + client/users_test.go | 198 + client/validate.go | 132 + client/validate_test.go | 201 + client/warnings.go | 89 + client/warnings_test.go | 121 + cmd/.indent.pro | 34 + cmd/Makefile.am | 538 + cmd/autogen.sh | 58 + cmd/configure.ac | 230 + cmd/decode-mount-opts/decode-mount-opts.c | 38 + .../apparmor-support.c | 141 + .../apparmor-support.h | 93 + .../cgroup-freezer-support.c | 125 + .../cgroup-freezer-support.h | 52 + cmd/libsnap-confine-private/cgroup-support.c | 99 + cmd/libsnap-confine-private/cgroup-support.h | 40 + cmd/libsnap-confine-private/classic-test.c | 190 + cmd/libsnap-confine-private/classic.c | 58 + cmd/libsnap-confine-private/classic.h | 33 + .../cleanup-funcs-test.c | 153 + cmd/libsnap-confine-private/cleanup-funcs.c | 61 + cmd/libsnap-confine-private/cleanup-funcs.h | 79 + cmd/libsnap-confine-private/error-test.c | 292 + cmd/libsnap-confine-private/error.c | 164 + 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 | 265 + cmd/libsnap-confine-private/infofile.c | 144 + 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 | 343 + cmd/libsnap-confine-private/mount-opt.c | 338 + cmd/libsnap-confine-private/mount-opt.h | 89 + cmd/libsnap-confine-private/mountinfo-test.c | 285 + cmd/libsnap-confine-private/mountinfo.c | 334 + 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 | 602 + cmd/libsnap-confine-private/snap.c | 315 + cmd/libsnap-confine-private/snap.h | 136 + .../string-utils-test.c | 872 ++ cmd/libsnap-confine-private/string-utils.c | 282 + cmd/libsnap-confine-private/string-utils.h | 116 + cmd/libsnap-confine-private/test-utils-test.c | 69 + cmd/libsnap-confine-private/test-utils.c | 108 + cmd/libsnap-confine-private/test-utils.h | 32 + cmd/libsnap-confine-private/tool.c | 239 + 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 | 203 + cmd/libsnap-confine-private/utils.c | 239 + cmd/libsnap-confine-private/utils.h | 104 + cmd/snap-bootstrap/README.md | 21 + cmd/snap-bootstrap/cmd_initramfs_mounts.go | 1412 ++ .../cmd_initramfs_mounts_nosecboot.go | 52 + ..._initramfs_mounts_recover_degraded_test.go | 292 + .../cmd_initramfs_mounts_secboot.go | 33 + .../cmd_initramfs_mounts_test.go | 4728 +++++++ .../cmd_recovery_chooser_trigger.go | 111 + .../cmd_recovery_chooser_trigger_test.go | 165 + cmd/snap-bootstrap/degraded-recover-mode.svg | 3 + cmd/snap-bootstrap/export_test.go | 146 + cmd/snap-bootstrap/initramfs_mounts_state.go | 100 + cmd/snap-bootstrap/initramfs_systemd_mount.go | 194 + .../initramfs_systemd_mount_test.go | 235 + cmd/snap-bootstrap/main.go | 87 + cmd/snap-bootstrap/main_test.go | 50 + cmd/snap-bootstrap/triggerwatch/evdev.go | 244 + .../triggerwatch/export_test.go | 32 + .../triggerwatch/triggerwatch.go | 87 + .../triggerwatch/triggerwatch_test.go | 130 + 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 | 606 + cmd/snap-confine/mount-support-nvidia.h | 48 + cmd/snap-confine/mount-support-test.c | 100 + cmd/snap-confine/mount-support.c | 820 ++ cmd/snap-confine/mount-support.h | 75 + cmd/snap-confine/ns-support-test.c | 154 + cmd/snap-confine/ns-support.c | 917 ++ 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.c | 193 + 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 | 146 + cmd/snap-confine/snap-confine-invocation.h | 80 + cmd/snap-confine/snap-confine.apparmor.in | 578 + cmd/snap-confine/snap-confine.c | 755 ++ cmd/snap-confine/snap-confine.rst | 185 + cmd/snap-confine/snap-device-helper | 81 + cmd/snap-confine/snap-device-helper-test.c | 261 + .../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 | 453 + cmd/snap-confine/udev-support.h | 23 + cmd/snap-confine/user-support.c | 71 + cmd/snap-confine/user-support.h | 25 + cmd/snap-discard-ns/snap-discard-ns.c | 228 + cmd/snap-discard-ns/snap-discard-ns.rst | 62 + cmd/snap-exec/export_test.go | 60 + cmd/snap-exec/main.go | 272 + cmd/snap-exec/main_test.go | 623 + cmd/snap-failure/cmd_snapd.go | 211 + cmd/snap-failure/cmd_snapd_test.go | 417 + cmd/snap-failure/export_test.go | 38 + cmd/snap-failure/main.go | 77 + cmd/snap-failure/main_test.go | 82 + cmd/snap-gdb-shim/snap-gdb-shim.c | 49 + cmd/snap-gdb-shim/snap-gdbserver-shim.c | 62 + cmd/snap-mgmt/snap-mgmt-selinux.sh.in | 112 + cmd/snap-mgmt/snap-mgmt.sh.in | 209 + cmd/snap-preseed/export_test.go | 66 + cmd/snap-preseed/main.go | 119 + cmd/snap-preseed/main_test.go | 705 + cmd/snap-preseed/preseed_linux.go | 283 + cmd/snap-preseed/preseed_other.go | 41 + cmd/snap-preseed/reset.go | 166 + cmd/snap-recovery-chooser/export_test.go | 65 + cmd/snap-recovery-chooser/main.go | 221 + cmd/snap-recovery-chooser/main_test.go | 547 + 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 | 103 + cmd/snap-repair/cmd_run_test.go | 94 + cmd/snap-repair/cmd_show.go | 91 + cmd/snap-repair/cmd_show_test.go | 142 + cmd/snap-repair/export_test.go | 147 + cmd/snap-repair/main.go | 94 + cmd/snap-repair/main_test.go | 104 + cmd/snap-repair/runner.go | 1101 ++ cmd/snap-repair/runner_test.go | 1871 +++ cmd/snap-repair/staging.go | 81 + cmd/snap-repair/trace.go | 176 + cmd/snap-repair/trace_test.go | 66 + 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 | 222 + cmd/snap-seccomp/export_test.go | 59 + cmd/snap-seccomp/main.go | 870 ++ cmd/snap-seccomp/main_ppc64le.go | 31 + cmd/snap-seccomp/main_test.go | 842 ++ cmd/snap-seccomp/syscalls/syscalls.go | 494 + cmd/snap-seccomp/versioninfo.go | 81 + cmd/snap-seccomp/versioninfo_test.go | 75 + 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 | 696 + cmd/snap-update-ns/change_test.go | 3030 +++++ cmd/snap-update-ns/common.go | 125 + cmd/snap-update-ns/common_test.go | 176 + cmd/snap-update-ns/export_test.go | 217 + cmd/snap-update-ns/main.go | 88 + cmd/snap-update-ns/main_test.go | 382 + cmd/snap-update-ns/secure_bindmount.go | 97 + cmd/snap-update-ns/secure_bindmount_test.go | 200 + cmd/snap-update-ns/sorting.go | 63 + cmd/snap-update-ns/sorting_test.go | 129 + cmd/snap-update-ns/system.go | 99 + cmd/snap-update-ns/system_test.go | 139 + cmd/snap-update-ns/trespassing.go | 296 + cmd/snap-update-ns/trespassing_test.go | 449 + cmd/snap-update-ns/update.go | 102 + cmd/snap-update-ns/update_test.go | 351 + cmd/snap-update-ns/user.go | 113 + cmd/snap-update-ns/user_test.go | 143 + cmd/snap-update-ns/utils.go | 655 + cmd/snap-update-ns/utils_test.go | 1178 ++ cmd/snap-update-ns/xdg.go | 56 + cmd/snap-update-ns/xdg_test.go | 56 + 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 | 261 + cmd/snap/cmd_alias.go | 118 + cmd/snap/cmd_alias_test.go | 75 + cmd/snap/cmd_aliases.go | 147 + cmd/snap/cmd_aliases_test.go | 179 + cmd/snap/cmd_auto_import.go | 396 + cmd/snap/cmd_auto_import_test.go | 562 + cmd/snap/cmd_booted.go | 50 + 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 | 209 + cmd/snap/cmd_changes_test.go | 233 + 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 | 92 + 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 | 57 + cmd/snap/cmd_debug_bootvars_test.go | 62 + 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_state.go | 335 + cmd/snap/cmd_debug_state_test.go | 232 + cmd/snap/cmd_debug_timings.go | 294 + cmd/snap/cmd_debug_timings_test.go | 337 + cmd/snap/cmd_debug_validate_seed.go | 50 + cmd/snap/cmd_debug_validate_seed_test.go | 45 + cmd/snap/cmd_delete_key.go | 61 + cmd/snap/cmd_delete_key_test.go | 64 + cmd/snap/cmd_disconnect.go | 108 + cmd/snap/cmd_disconnect_test.go | 270 + cmd/snap/cmd_download.go | 184 + 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 | 100 + cmd/snap/cmd_export_key_test.go | 84 + cmd/snap/cmd_find.go | 274 + cmd/snap/cmd_find_test.go | 594 + cmd/snap/cmd_first_boot.go | 50 + cmd/snap/cmd_get.go | 258 + cmd/snap/cmd_get_base_declaration.go | 69 + cmd/snap/cmd_get_base_declaration_test.go | 80 + cmd/snap/cmd_get_test.go | 226 + cmd/snap/cmd_handle_link.go | 91 + cmd/snap/cmd_help.go | 365 + cmd/snap/cmd_help_test.go | 224 + cmd/snap/cmd_info.go | 762 ++ cmd/snap/cmd_info_test.go | 1223 ++ 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 | 144 + cmd/snap/cmd_known.go | 147 + cmd/snap/cmd_known_test.go | 230 + cmd/snap/cmd_list.go | 134 + cmd/snap/cmd_list_test.go | 256 + 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 | 337 + cmd/snap/cmd_model_test.go | 593 + cmd/snap/cmd_pack.go | 125 + cmd/snap/cmd_pack_test.go | 142 + 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 | 121 + cmd/snap/cmd_prepare_image_test.go | 141 + cmd/snap/cmd_reboot.go | 125 + cmd/snap/cmd_reboot_test.go | 168 + cmd/snap/cmd_recovery.go | 116 + cmd/snap/cmd_recovery_test.go | 194 + cmd/snap/cmd_remodel.go | 86 + 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 | 496 + cmd/snap/cmd_routine_file_access.go | 216 + cmd/snap/cmd_routine_file_access_test.go | 185 + cmd/snap/cmd_routine_portal_info.go | 152 + cmd/snap/cmd_routine_portal_info_test.go | 188 + cmd/snap/cmd_run.go | 1179 ++ cmd/snap/cmd_run_test.go | 1571 +++ cmd/snap/cmd_sandbox_features.go | 88 + cmd/snap/cmd_sandbox_features_test.go | 61 + cmd/snap/cmd_services.go | 264 + cmd/snap/cmd_services_test.go | 296 + cmd/snap/cmd_set.go | 106 + cmd/snap/cmd_set_test.go | 130 + cmd/snap/cmd_sign.go | 106 + cmd/snap/cmd_sign_build.go | 124 + cmd/snap/cmd_sign_build_test.go | 134 + cmd/snap/cmd_sign_test.go | 65 + cmd/snap/cmd_snap_op.go | 1146 ++ cmd/snap/cmd_snap_op_test.go | 2135 +++ 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 | 85 + cmd/snap/cmd_unset_test.go | 47 + cmd/snap/cmd_userd.go | 134 + cmd/snap/cmd_userd_test.go | 194 + cmd/snap/cmd_validate.go | 198 + cmd/snap/cmd_validate_test.go | 235 + 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 | 154 + cmd/snap/cmd_whoami.go | 58 + cmd/snap/cmd_whoami_test.go | 69 + cmd/snap/color.go | 201 + cmd/snap/color_test.go | 196 + cmd/snap/complete.go | 514 + cmd/snap/error.go | 428 + cmd/snap/export_test.go | 402 + cmd/snap/fallocate_darwin.go | 28 + cmd/snap/fallocate_linux.go | 35 + cmd/snap/gnupg2_test.go | 27 + cmd/snap/interfaces_common.go | 55 + cmd/snap/interfaces_common_test.go | 56 + cmd/snap/last.go | 106 + cmd/snap/main.go | 593 + cmd/snap/main_test.go | 431 + cmd/snap/notes.go | 174 + cmd/snap/notes_test.go | 117 + 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 | 166 + cmd/snapctl/main.go | 108 + cmd/snapctl/main_test.go | 130 + cmd/snapd-apparmor/snapd-apparmor | 100 + cmd/snapd-env-generator/main.c | 51 + .../snapd-env-generator.rst | 30 + cmd/snapd-generator/main.c | 253 + cmd/snapd/export_test.go | 44 + cmd/snapd/main.go | 169 + cmd/snapd/main_test.go | 129 + cmd/snaplock/lock.go | 48 + cmd/snaplock/lock_test.go | 59 + cmd/snaplock/runinhibit/inhibit.go | 154 + cmd/snaplock/runinhibit/inhibit_test.go | 156 + .../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 + daemon/api.go | 600 + daemon/api_aliases.go | 171 + daemon/api_aliases_test.go | 643 + daemon/api_apps.go | 286 + daemon/api_apps_test.go | 722 + daemon/api_asserts.go | 182 + daemon/api_asserts_test.go | 520 + daemon/api_base_d_test.go | 615 + daemon/api_base_test.go | 579 + daemon/api_buy_unsupp.go | 120 + daemon/api_buy_unsupp_test.go | 254 + daemon/api_cohort.go | 60 + daemon/api_cohort_test.go | 145 + daemon/api_connections.go | 287 + daemon/api_connections_test.go | 1044 ++ daemon/api_console_conf.go | 96 + daemon/api_console_conf_test.go | 116 + daemon/api_debug.go | 335 + daemon/api_debug_pprof.go | 47 + daemon/api_debug_pprof_test.go | 55 + daemon/api_debug_seeding.go | 131 + daemon/api_debug_seeding_test.go | 217 + daemon/api_debug_test.go | 291 + daemon/api_download.go | 265 + daemon/api_download_test.go | 425 + daemon/api_find.go | 245 + daemon/api_find_test.go | 566 + daemon/api_general.go | 452 + daemon/api_general_test.go | 744 ++ daemon/api_icons.go | 70 + daemon/api_icons_test.go | 122 + daemon/api_interfaces.go | 232 + daemon/api_interfaces_test.go | 1055 ++ daemon/api_json.go | 91 + daemon/api_model.go | 183 + daemon/api_model_test.go | 391 + daemon/api_sections.go | 66 + daemon/api_sideload_n_try.go | 270 + daemon/api_sideload_n_try_test.go | 645 + daemon/api_snap_conf.go | 123 + daemon/api_snap_conf_test.go | 329 + daemon/api_snap_file.go | 68 + daemon/api_snap_file_test.go | 100 + daemon/api_snapctl.go | 114 + daemon/api_snapctl_test.go | 91 + daemon/api_snaps.go | 314 + daemon/api_snaps_test.go | 687 + daemon/api_snapshots.go | 228 + daemon/api_snapshots_test.go | 439 + daemon/api_system_recovery_keys.go | 54 + daemon/api_system_recovery_keys_test.go | 92 + daemon/api_systems.go | 169 + daemon/api_systems_test.go | 678 + daemon/api_test.go | 2086 +++ daemon/api_users.go | 608 + daemon/api_users_test.go | 870 ++ daemon/api_validate.go | 291 + daemon/api_validate_test.go | 434 + daemon/command_counter_test.go | 220 + daemon/daemon.go | 785 ++ daemon/daemon_test.go | 1309 ++ daemon/export_api_aliases_test.go | 25 + daemon/export_api_apps_test.go | 46 + daemon/export_api_asserts_test.go | 39 + daemon/export_api_cohort_test.go | 24 + daemon/export_api_download_test.go | 31 + daemon/export_api_general_test.go | 58 + daemon/export_api_model_test.go | 38 + daemon/export_api_sideload_n_try_test.go | 24 + daemon/export_api_snap_file_test.go | 29 + daemon/export_api_snapctl_test.go | 32 + daemon/export_api_snapshots_test.go | 116 + daemon/export_api_systems_test.go | 36 + daemon/export_api_users_test.go | 66 + daemon/export_api_validate_test.go | 24 + daemon/export_snap_test.go | 24 + daemon/export_test.go | 172 + daemon/response.go | 643 + daemon/response_test.go | 139 + daemon/snap.go | 222 + daemon/ucrednet.go | 144 + daemon/ucrednet_test.go | 201 + data/Makefile | 7 + 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 | 46 + .../io.snapcraft.SessionAgent.desktop.in | 7 + data/desktop/snap-handle-link.desktop.in | 7 + data/desktop/snap-userd-autostart.desktop.in | 6 + data/env/Makefile | 37 + data/env/snapd.sh.in | 22 + data/failure.txt | 8 + data/polkit/io.snapcraft.snapd.policy | 40 + 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 | 846 ++ 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-user/Makefile | 40 + .../snapd.session-agent.service.in | 7 + data/systemd-user/snapd.session-agent.socket | 8 + data/systemd/Makefile | 52 + data/systemd/snapd.apparmor.service.in | 22 + 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 + .../snapd.recovery-chooser-trigger.service.in | 19 + data/systemd/snapd.run-from-snap | 6 + data/systemd/snapd.seeded.service.in | 14 + data/systemd/snapd.service.in | 23 + data/systemd/snapd.snap-repair.service.in | 15 + data/systemd/snapd.snap-repair.timer | 16 + 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 | 233 + dbusutil/dbustest/stub.go | 47 + dbusutil/dbusutil.go | 112 + dbusutil/dbusutil_test.go | 123 + dbusutil/export_test.go | 22 + debian | 1 + desktop/notification/caps.go | 67 + desktop/notification/export_test.go | 24 + desktop/notification/fdo.go | 199 + desktop/notification/fdo_test.go | 640 + desktop/notification/hints.go | 206 + desktop/notification/hints_test.go | 101 + desktop/notification/notify.go | 152 + desktop/notification/notify_test.go | 42 + dirs/dirs.go | 488 + dirs/dirs_test.go | 208 + dirs/export_test.go | 32 + docs/MOVED.md | 1 + docs/error-kinds.go | 100 + errtracker/errtracker.go | 516 + errtracker/errtracker_test.go | 559 + errtracker/export_test.go | 126 + features/export_test.go | 24 + features/features.go | 192 + features/features_test.go | 170 + gadget/device_darwin.go | 40 + gadget/device_linux.go | 302 + gadget/device_test.go | 759 ++ gadget/edition/number.go | 45 + gadget/edition/number_test.go | 69 + gadget/export_test.go | 70 + gadget/gadget.go | 992 ++ gadget/gadget_test.go | 2298 ++++ gadget/install/content.go | 143 + gadget/install/content_test.go | 332 + gadget/install/encrypt.go | 95 + gadget/install/encrypt_test.go | 178 + gadget/install/export_secboot_test.go | 47 + gadget/install/export_test.go | 71 + gadget/install/install.go | 292 + gadget/install/install_dummy.go | 31 + gadget/install/install_test.go | 450 + gadget/install/mount_linux.go | 29 + gadget/install/mount_other.go | 35 + gadget/install/params.go | 44 + gadget/install/partition.go | 309 + gadget/install/partition_test.go | 679 + gadget/internal/mkfs.go | 193 + gadget/internal/mkfs_test.go | 378 + gadget/layout.go | 451 + gadget/layout_test.go | 1293 ++ gadget/mountedfilesystem.go | 958 ++ gadget/mountedfilesystem_test.go | 3269 +++++ gadget/ondisk.go | 261 + gadget/ondisk_test.go | 482 + 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 | 814 ++ gadget/update.go | 438 + gadget/update_test.go | 1594 +++ gadget/validate.go | 356 + gadget/validate_test.go | 812 ++ gen-coverage.sh | 9 + generate-packaging-dir | 17 + get-deps.sh | 37 + httputil/client.go | 186 + httputil/client_test.go | 285 + httputil/export_test.go | 24 + httputil/logger.go | 104 + httputil/logger_test.go | 182 + httputil/retry.go | 293 + httputil/retry_test.go | 508 + httputil/transport.go | 41 + i18n/i18n.go | 119 + i18n/i18n_test.go | 162 + i18n/xgettext-go/main.go | 350 + i18n/xgettext-go/main_test.go | 515 + image/export_test.go | 53 + image/helpers.go | 406 + image/helpers_test.go | 132 + image/image_darwin.go | 28 + image/image_linux.go | 445 + image/image_test.go | 2845 ++++ image/options.go | 42 + include/lk/snappy_boot_common.h | 56 + include/lk/snappy_boot_v1.h | 143 + include/lk/snappy_boot_v2.h | 228 + interfaces/apparmor/apparmor.go | 206 + interfaces/apparmor/apparmor_test.go | 251 + interfaces/apparmor/backend.go | 797 ++ interfaces/apparmor/backend_test.go | 2351 ++++ interfaces/apparmor/export_test.go | 120 + interfaces/apparmor/spec.go | 633 + interfaces/apparmor/spec_test.go | 526 + interfaces/apparmor/template.go | 1034 ++ interfaces/apparmor/template_vars.go | 45 + interfaces/backend.go | 113 + interfaces/backends/backends.go | 77 + interfaces/backends/backends_test.go | 76 + interfaces/backends/export_test.go | 24 + interfaces/builtin/account_control.go | 126 + interfaces/builtin/account_control_test.go | 108 + interfaces/builtin/accounts_service.go | 80 + interfaces/builtin/accounts_service_test.go | 81 + interfaces/builtin/adb_support.go | 191 + interfaces/builtin/adb_support_test.go | 161 + interfaces/builtin/all.go | 155 + interfaces/builtin/all_test.go | 421 + interfaces/builtin/alsa.go | 69 + interfaces/builtin/alsa_test.go | 109 + interfaces/builtin/appstream_metadata.go | 116 + interfaces/builtin/appstream_metadata_test.go | 136 + interfaces/builtin/audio_playback.go | 181 + interfaces/builtin/audio_playback_test.go | 209 + 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 | 121 + interfaces/builtin/block_devices_test.go | 114 + interfaces/builtin/bluetooth_control.go | 77 + interfaces/builtin/bluetooth_control_test.go | 115 + interfaces/builtin/bluez.go | 306 + interfaces/builtin/bluez_test.go | 279 + interfaces/builtin/bool_file.go | 151 + interfaces/builtin/bool_file_test.go | 231 + interfaces/builtin/broadcom_asic_control.go | 78 + .../builtin/broadcom_asic_control_test.go | 124 + interfaces/builtin/browser_support.go | 405 + interfaces/builtin/browser_support_test.go | 200 + interfaces/builtin/calendar_service.go | 141 + interfaces/builtin/calendar_service_test.go | 93 + interfaces/builtin/camera.go | 69 + 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 | 187 + interfaces/builtin/common_files.go | 166 + interfaces/builtin/common_test.go | 236 + interfaces/builtin/contacts_service.go | 160 + interfaces/builtin/contacts_service_test.go | 93 + interfaces/builtin/content.go | 318 + interfaces/builtin/content_test.go | 1023 ++ interfaces/builtin/core_support.go | 51 + interfaces/builtin/core_support_test.go | 88 + interfaces/builtin/cpu_control.go | 66 + interfaces/builtin/cpu_control_test.go | 88 + interfaces/builtin/cups.go | 62 + interfaces/builtin/cups_control.go | 177 + interfaces/builtin/cups_control_test.go | 183 + interfaces/builtin/cups_test.go | 101 + interfaces/builtin/daemon_notify.go | 103 + interfaces/builtin/daemon_notify_test.go | 166 + interfaces/builtin/dbus.go | 442 + interfaces/builtin/dbus_test.go | 700 + interfaces/builtin/dcdbas_control.go | 63 + interfaces/builtin/dcdbas_control_test.go | 87 + interfaces/builtin/desktop.go | 391 + interfaces/builtin/desktop_legacy.go | 394 + interfaces/builtin/desktop_legacy_test.go | 109 + interfaces/builtin/desktop_test.go | 221 + interfaces/builtin/device_buttons.go | 92 + interfaces/builtin/device_buttons_test.go | 113 + interfaces/builtin/display_control.go | 136 + interfaces/builtin/display_control_test.go | 118 + interfaces/builtin/docker.go | 55 + interfaces/builtin/docker_support.go | 710 + interfaces/builtin/docker_support_test.go | 273 + interfaces/builtin/docker_test.go | 96 + interfaces/builtin/dummy.go | 101 + interfaces/builtin/dvb.go | 51 + interfaces/builtin/dvb_test.go | 106 + interfaces/builtin/export_test.go | 118 + 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 | 322 + interfaces/builtin/fwupd_test.go | 246 + 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 | 61 + interfaces/builtin/gpg_public_keys_test.go | 96 + interfaces/builtin/gpio.go | 131 + interfaces/builtin/gpio_control.go | 59 + 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 | 156 + interfaces/builtin/greengrass_support.go | 475 + interfaces/builtin/greengrass_support_test.go | 251 + interfaces/builtin/gsettings.go | 57 + interfaces/builtin/gsettings_test.go | 104 + interfaces/builtin/hardware_observe.go | 165 + 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 | 367 + interfaces/builtin/home.go | 132 + interfaces/builtin/home_test.go | 176 + interfaces/builtin/hostname_control.go | 89 + interfaces/builtin/hostname_control_test.go | 103 + interfaces/builtin/hugepages_control.go | 76 + interfaces/builtin/hugepages_control_test.go | 104 + interfaces/builtin/i2c.go | 155 + interfaces/builtin/i2c_test.go | 283 + interfaces/builtin/iio.go | 151 + interfaces/builtin/iio_test.go | 264 + 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/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_module_control.go | 84 + .../builtin/kernel_module_control_test.go | 117 + interfaces/builtin/kernel_module_observe.go | 59 + .../builtin/kernel_module_observe_test.go | 95 + interfaces/builtin/kubernetes_support.go | 382 + interfaces/builtin/kubernetes_support_test.go | 294 + interfaces/builtin/kvm.go | 113 + interfaces/builtin/kvm_test.go | 202 + interfaces/builtin/libvirt.go | 52 + interfaces/builtin/libvirt_test.go | 61 + interfaces/builtin/locale_control.go | 48 + interfaces/builtin/locale_control_test.go | 89 + interfaces/builtin/location_control.go | 256 + interfaces/builtin/location_control_test.go | 206 + interfaces/builtin/location_observe.go | 310 + interfaces/builtin/location_observe_test.go | 200 + interfaces/builtin/log_observe.go | 84 + interfaces/builtin/log_observe_test.go | 87 + interfaces/builtin/login_session_control.go | 75 + .../builtin/login_session_control_test.go | 86 + interfaces/builtin/login_session_observe.go | 127 + .../builtin/login_session_observe_test.go | 95 + interfaces/builtin/lxd.go | 53 + interfaces/builtin/lxd_support.go | 69 + interfaces/builtin/lxd_support_test.go | 111 + interfaces/builtin/lxd_test.go | 111 + interfaces/builtin/maliit.go | 173 + interfaces/builtin/maliit_test.go | 256 + interfaces/builtin/media_hub.go | 204 + interfaces/builtin/media_hub_test.go | 197 + interfaces/builtin/mir.go | 157 + interfaces/builtin/mir_test.go | 158 + interfaces/builtin/modem_manager.go | 1288 ++ interfaces/builtin/modem_manager_test.go | 252 + interfaces/builtin/mount_observe.go | 76 + interfaces/builtin/mount_observe_test.go | 87 + interfaces/builtin/mpris.go | 248 + interfaces/builtin/mpris_test.go | 338 + interfaces/builtin/multipass_support.go | 128 + interfaces/builtin/multipass_support_test.go | 108 + interfaces/builtin/netlink_audit.go | 64 + interfaces/builtin/netlink_audit_test.go | 96 + interfaces/builtin/netlink_connector.go | 61 + interfaces/builtin/netlink_connector_test.go | 87 + interfaces/builtin/network.go | 103 + interfaces/builtin/network_bind.go | 108 + interfaces/builtin/network_bind_test.go | 95 + interfaces/builtin/network_control.go | 351 + interfaces/builtin/network_control_test.go | 141 + interfaces/builtin/network_manager.go | 559 + interfaces/builtin/network_manager_observe.go | 216 + .../builtin/network_manager_observe_test.go | 145 + interfaces/builtin/network_manager_test.go | 246 + interfaces/builtin/network_observe.go | 163 + interfaces/builtin/network_observe_test.go | 96 + interfaces/builtin/network_setup_control.go | 78 + .../builtin/network_setup_control_test.go | 87 + interfaces/builtin/network_setup_observe.go | 66 + .../builtin/network_setup_observe_test.go | 88 + interfaces/builtin/network_status.go | 58 + interfaces/builtin/network_status_test.go | 90 + interfaces/builtin/network_test.go | 96 + 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 | 171 + interfaces/builtin/opengl_test.go | 120 + 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/personal_files.go | 78 + interfaces/builtin/personal_files_test.go | 152 + interfaces/builtin/physical_memory_control.go | 56 + .../builtin/physical_memory_control_test.go | 109 + interfaces/builtin/physical_memory_observe.go | 52 + .../builtin/physical_memory_observe_test.go | 109 + interfaces/builtin/power_control.go | 60 + interfaces/builtin/power_control_test.go | 88 + interfaces/builtin/ppp.go | 74 + interfaces/builtin/ppp_test.go | 118 + interfaces/builtin/process_control.go | 76 + interfaces/builtin/process_control_test.go | 95 + interfaces/builtin/ptp.go | 55 + interfaces/builtin/ptp_test.go | 109 + interfaces/builtin/pulseaudio.go | 186 + interfaces/builtin/pulseaudio_test.go | 141 + interfaces/builtin/raw_usb.go | 80 + interfaces/builtin/raw_usb_test.go | 121 + interfaces/builtin/raw_volume.go | 155 + interfaces/builtin/raw_volume_test.go | 350 + interfaces/builtin/removable_media.go | 61 + interfaces/builtin/removable_media_test.go | 88 + interfaces/builtin/screen_inhibit_control.go | 92 + .../builtin/screen_inhibit_control_test.go | 87 + interfaces/builtin/screencast_legacy.go | 63 + interfaces/builtin/screencast_legacy_test.go | 99 + interfaces/builtin/serial_port.go | 287 + interfaces/builtin/serial_port_test.go | 639 + interfaces/builtin/shutdown.go | 74 + interfaces/builtin/shutdown_test.go | 86 + 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 | 51 + interfaces/builtin/ssh_keys_test.go | 97 + interfaces/builtin/ssh_public_keys.go | 50 + interfaces/builtin/ssh_public_keys_test.go | 96 + .../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 | 153 + interfaces/builtin/system_observe_test.go | 97 + interfaces/builtin/system_packages_doc.go | 76 + .../builtin/system_packages_doc_test.go | 125 + 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/thumbnailer_service.go | 148 + .../builtin/thumbnailer_service_test.go | 124 + interfaces/builtin/time_control.go | 139 + interfaces/builtin/time_control_test.go | 129 + interfaces/builtin/timeserver_control.go | 99 + interfaces/builtin/timeserver_control_test.go | 87 + interfaces/builtin/timezone_control.go | 101 + interfaces/builtin/timezone_control_test.go | 87 + interfaces/builtin/tpm.go | 55 + interfaces/builtin/tpm_test.go | 111 + interfaces/builtin/u2f_devices.go | 170 + 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 | 460 + interfaces/builtin/udisks2_test.go | 295 + 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 | 122 + interfaces/builtin/uio_test.go | 136 + 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 | 273 + interfaces/builtin/upower_observe_test.go | 232 + interfaces/builtin/utils.go | 183 + interfaces/builtin/utils_test.go | 309 + interfaces/builtin/vcio.go | 65 + interfaces/builtin/vcio_test.go | 105 + interfaces/builtin/wayland.go | 171 + interfaces/builtin/wayland_test.go | 202 + interfaces/builtin/x11.go | 271 + interfaces/builtin/x11_test.go | 284 + interfaces/connection.go | 284 + interfaces/connection_test.go | 359 + interfaces/core.go | 286 + interfaces/core_test.go | 283 + interfaces/dbus/backend.go | 240 + interfaces/dbus/backend_test.go | 387 + interfaces/dbus/dbus.go | 52 + interfaces/dbus/dbus_test.go | 42 + interfaces/dbus/export_test.go | 35 + interfaces/dbus/spec.go | 132 + interfaces/dbus/spec_test.go | 105 + interfaces/dbus/template.go | 29 + interfaces/export_test.go | 75 + interfaces/helpers.go | 54 + interfaces/helpers_test.go | 172 + 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 | 113 + interfaces/ifacetest/backendtest.go | 228 + interfaces/ifacetest/ifacetest_test.go | 30 + interfaces/ifacetest/spec.go | 81 + interfaces/ifacetest/spec_test.go | 93 + interfaces/ifacetest/testiface.go | 436 + interfaces/ifacetest/testiface_test.go | 239 + interfaces/kmod/backend.go | 144 + interfaces/kmod/backend_test.go | 142 + interfaces/kmod/export_test.go | 24 + interfaces/kmod/kmod.go | 39 + interfaces/kmod/kmod_test.go | 73 + interfaces/kmod/spec.go | 104 + interfaces/kmod/spec_test.go | 129 + interfaces/mount/backend.go | 150 + interfaces/mount/backend_test.go | 248 + interfaces/mount/ns.go | 69 + interfaces/mount/ns_test.go | 144 + interfaces/mount/spec.go | 253 + interfaces/mount/spec_test.go | 226 + interfaces/naming.go | 34 + interfaces/naming_test.go | 38 + interfaces/policy/basedeclaration.go | 199 + interfaces/policy/basedeclaration_test.go | 1202 ++ interfaces/policy/export_test.go | 26 + interfaces/policy/helpers.go | 354 + interfaces/policy/helpers_test.go | 100 + interfaces/policy/policy.go | 317 + interfaces/policy/policy_test.go | 2848 ++++ interfaces/repo.go | 1155 ++ interfaces/repo_test.go | 2215 ++++ interfaces/seccomp/backend.go | 496 + interfaces/seccomp/backend_test.go | 970 ++ interfaces/seccomp/export_test.go | 101 + interfaces/seccomp/seccomp_test.go | 30 + interfaces/seccomp/spec.go | 137 + interfaces/seccomp/spec_test.go | 105 + interfaces/seccomp/template.go | 818 ++ interfaces/sorting.go | 106 + interfaces/sorting_test.go | 134 + interfaces/system_key.go | 321 + interfaces/system_key_test.go | 414 + interfaces/systemd/backend.go | 197 + interfaces/systemd/backend_test.go | 209 + interfaces/systemd/service.go | 58 + interfaces/systemd/service_test.go | 45 + interfaces/systemd/spec.go | 107 + interfaces/systemd/spec_test.go | 65 + interfaces/systemd/systemd_test.go | 30 + interfaces/udev/backend.go | 189 + interfaces/udev/backend_test.go | 535 + interfaces/udev/export_test.go | 24 + interfaces/udev/spec.go | 217 + interfaces/udev/spec_test.go | 168 + interfaces/udev/udev.go | 111 + interfaces/udev/udev_test.go | 192 + 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/kernel.go | 79 + kernel/kernel_test.go | 139 + kernel/validate.go | 72 + kernel/validate_test.go | 114 + logger/export_test.go | 44 + logger/logger.go | 176 + logger/logger_test.go | 148 + mdlint.py | 37 + metautil/normalize.go | 80 + metautil/normalize_test.go | 71 + mkversion.sh | 132 + netutil/activation.go | 85 + netutil/metered.go | 65 + osutil/bootid.go | 40 + osutil/bootid_test.go | 36 + osutil/buildid.go | 116 + osutil/buildid_test.go | 164 + osutil/chattr.go | 76 + osutil/chattr_32.go | 27 + osutil/chattr_64.go | 28 + osutil/chdir.go | 38 + osutil/chdir_test.go | 59 + osutil/cmp.go | 103 + osutil/cmp_test.go | 156 + osutil/context.go | 89 + osutil/context_test.go | 138 + osutil/cp.go | 212 + osutil/cp_linux.go | 48 + osutil/cp_linux_test.go | 45 + osutil/cp_other.go | 31 + osutil/cp_test.go | 373 + osutil/digest.go | 46 + osutil/digest_test.go | 50 + osutil/disk.go | 61 + osutil/disk_test.go | 70 + osutil/disks/disks.go | 95 + osutil/disks/disks_darwin.go | 42 + osutil/disks/disks_linux.go | 508 + osutil/disks/disks_linux_test.go | 730 ++ osutil/disks/export_test.go | 43 + osutil/disks/labels.go | 48 + osutil/disks/labels_test.go | 89 + osutil/disks/mockdisk.go | 190 + osutil/disks/mockdisk_test.go | 301 + osutil/doc.go | 23 + osutil/env.go | 259 + osutil/env_test.go | 332 + osutil/exec.go | 170 + osutil/exec_test.go | 162 + osutil/exitcode.go | 39 + osutil/exitcode_test.go | 56 + osutil/export_test.go | 222 + osutil/flock.go | 106 + osutil/flock_test.go | 245 + osutil/fshelpers.go | 39 + osutil/fshelpers_test.go | 51 + osutil/group.go | 191 + osutil/group_cgo.go | 29 + osutil/group_no_cgo.go | 21 + osutil/group_test.go | 227 + osutil/io.go | 326 + osutil/io_test.go | 472 + osutil/kcmdline.go | 214 + osutil/kcmdline_test.go | 210 + osutil/mkdirallchown.go | 87 + osutil/mkdirallchown_test.go | 48 + osutil/mockable.go | 68 + 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 | 341 + osutil/mountentry_linux.go | 144 + osutil/mountentry_linux_test.go | 440 + osutil/mountinfo_linux.go | 213 + osutil/mountinfo_linux_test.go | 202 + osutil/mountprofile_linux.go | 122 + osutil/mountprofile_linux_test.go | 169 + osutil/nfs_darwin.go | 25 + osutil/nfs_linux.go | 52 + osutil/nfs_linux_test.go | 98 + osutil/osutil_darwin.go | 27 + osutil/osutil_test.go | 29 + osutil/outputerr.go | 39 + osutil/outputerr_test.go | 54 + osutil/overlay_darwin.go | 25 + osutil/overlay_linux.go | 93 + osutil/overlay_linux_test.go | 107 + osutil/sizer.go | 38 + osutil/sizer_test.go | 47 + osutil/squashfs/fstype.go | 89 + osutil/stat.go | 126 + osutil/stat_test.go | 235 + osutil/strace/export_test.go | 32 + osutil/strace/strace.go | 93 + osutil/strace/strace_test.go | 118 + osutil/strace/timing.go | 228 + osutil/strace/timing_test.go | 137 + osutil/syncdir.go | 242 + osutil/syncdir_test.go | 243 + osutil/synctree.go | 191 + osutil/synctree_test.go | 187 + osutil/sys/syscall.go | 109 + osutil/sys/sysnum_16_linux.go | 33 + osutil/sys/sysnum_32_linux.go | 32 + osutil/sys/sysnum_darwin.go | 30 + 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/go.mod | 5 + osutil/udev/main.go.sample | 145 + 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 | 14 + 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 | 341 + osutil/user_test.go | 561 + osutil/winsize.go | 48 + overlord/assertstate/assertmgr.go | 147 + overlord/assertstate/assertstate.go | 338 + overlord/assertstate/assertstate_test.go | 2078 +++ .../assertstate/assertstatetest/add_many.go | 42 + overlord/assertstate/bulk.go | 248 + overlord/assertstate/export_test.go | 33 + overlord/assertstate/helpers.go | 82 + .../assertstate/validation_set_tracking.go | 127 + .../validation_set_tracking_test.go | 159 + overlord/auth/auth.go | 371 + overlord/auth/auth_test.go | 457 + overlord/backend.go | 45 + overlord/cmdstate/cmdmgr.go | 90 + overlord/cmdstate/cmdstate.go | 37 + overlord/cmdstate/cmdstate_test.go | 223 + overlord/cmdstate/export_test.go | 32 + overlord/configstate/config/export_test.go | 30 + overlord/configstate/config/helpers.go | 287 + overlord/configstate/config/helpers_test.go | 286 + overlord/configstate/config/transaction.go | 357 + .../configstate/config/transaction_test.go | 533 + overlord/configstate/configcore/backlight.go | 71 + .../configstate/configcore/backlight_test.go | 90 + overlord/configstate/configcore/certs.go | 117 + overlord/configstate/configcore/certs_test.go | 147 + overlord/configstate/configcore/cloud.go | 123 + overlord/configstate/configcore/cloud_test.go | 210 + overlord/configstate/configcore/corecfg.go | 104 + .../configstate/configcore/corecfg_test.go | 229 + .../configstate/configcore/experimental.go | 81 + .../configcore/experimental_test.go | 98 + .../configstate/configcore/export_test.go | 50 + overlord/configstate/configcore/handlers.go | 165 + overlord/configstate/configcore/journal.go | 121 + .../configstate/configcore/journal_test.go | 201 + overlord/configstate/configcore/network.go | 94 + .../configstate/configcore/network_test.go | 133 + overlord/configstate/configcore/picfg.go | 115 + overlord/configstate/configcore/picfg_test.go | 185 + overlord/configstate/configcore/powerbtn.go | 90 + .../configstate/configcore/powerbtn_test.go | 85 + overlord/configstate/configcore/proxy.go | 122 + overlord/configstate/configcore/proxy_test.go | 201 + overlord/configstate/configcore/refresh.go | 135 + .../configstate/configcore/refresh_test.go | 169 + .../configstate/configcore/runwithstate.go | 132 + overlord/configstate/configcore/services.go | 212 + .../configstate/configcore/services_test.go | 330 + overlord/configstate/configcore/snapshots.go | 50 + .../configstate/configcore/snapshots_test.go | 72 + overlord/configstate/configcore/sysctl.go | 118 + .../configstate/configcore/sysctl_test.go | 140 + overlord/configstate/configcore/timezone.go | 96 + .../configstate/configcore/timezone_test.go | 102 + overlord/configstate/configcore/utils.go | 97 + overlord/configstate/configcore/utils_test.go | 65 + overlord/configstate/configcore/vitality.go | 156 + .../configstate/configcore/vitality_test.go | 272 + overlord/configstate/configcore/watchdog.go | 143 + .../configstate/configcore/watchdog_test.go | 274 + overlord/configstate/configmgr.go | 72 + overlord/configstate/configstate.go | 142 + overlord/configstate/configstate_test.go | 371 + overlord/configstate/export_test.go | 23 + overlord/configstate/handler_test.go | 438 + overlord/configstate/helpers.go | 42 + overlord/configstate/helpers_test.go | 45 + overlord/configstate/hooks.go | 137 + overlord/configstate/proxyconf/proxy.go | 57 + overlord/configstate/proxyconf/proxy_test.go | 74 + overlord/configstate/settings/settings.go | 41 + .../configstate/settings/settings_test.go | 60 + overlord/devicestate/crypto.go | 80 + overlord/devicestate/devicectx.go | 116 + overlord/devicestate/devicemgr.go | 1492 +++ overlord/devicestate/devicestate.go | 639 + .../devicestate/devicestate_cloudinit_test.go | 1139 ++ .../devicestate/devicestate_gadget_test.go | 908 ++ .../devicestate_install_mode_test.go | 1081 ++ .../devicestate/devicestate_remodel_test.go | 1564 +++ .../devicestate/devicestate_serial_test.go | 2015 +++ .../devicestate/devicestate_systems_test.go | 829 ++ overlord/devicestate/devicestate_test.go | 1569 +++ .../devicestate/devicestatetest/devicesvc.go | 255 + .../devicestate/devicestatetest/gadget.go | 106 + overlord/devicestate/devicestatetest/state.go | 34 + overlord/devicestate/export_test.go | 305 + overlord/devicestate/fde/fde.go | 64 + overlord/devicestate/fde/fde_test.go | 59 + overlord/devicestate/firstboot.go | 367 + overlord/devicestate/firstboot20_test.go | 449 + .../devicestate/firstboot_preseed_test.go | 483 + overlord/devicestate/firstboot_test.go | 1889 +++ overlord/devicestate/handlers.go | 174 + overlord/devicestate/handlers_gadget.go | 174 + overlord/devicestate/handlers_install.go | 369 + overlord/devicestate/handlers_remodel.go | 216 + overlord/devicestate/handlers_serial.go | 848 ++ overlord/devicestate/handlers_test.go | 633 + overlord/devicestate/helpers.go | 47 + overlord/devicestate/internal/state.go | 63 + overlord/devicestate/internal/state_test.go | 58 + overlord/devicestate/remodel.go | 470 + overlord/devicestate/remodel_test.go | 933 ++ overlord/devicestate/systems.go | 178 + overlord/export_test.go | 99 + overlord/healthstate/export_test.go | 34 + overlord/healthstate/healthstate.go | 226 + overlord/healthstate/healthstate_test.go | 255 + overlord/hookstate/context.go | 312 + overlord/hookstate/context_test.go | 186 + overlord/hookstate/ctlcmd/ctlcmd.go | 154 + overlord/hookstate/ctlcmd/ctlcmd_test.go | 114 + overlord/hookstate/ctlcmd/export_test.go | 90 + overlord/hookstate/ctlcmd/fde_setup.go | 139 + overlord/hookstate/ctlcmd/fde_setup_test.go | 174 + overlord/hookstate/ctlcmd/get.go | 338 + 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 | 235 + overlord/hookstate/ctlcmd/is_connected.go | 111 + .../hookstate/ctlcmd/is_connected_test.go | 192 + overlord/hookstate/ctlcmd/restart.go | 56 + overlord/hookstate/ctlcmd/services.go | 101 + overlord/hookstate/ctlcmd/services_test.go | 627 + overlord/hookstate/ctlcmd/set.go | 214 + overlord/hookstate/ctlcmd/set_test.go | 354 + overlord/hookstate/ctlcmd/start.go | 56 + overlord/hookstate/ctlcmd/stop.go | 56 + overlord/hookstate/ctlcmd/unset.go | 74 + overlord/hookstate/ctlcmd/unset_test.go | 137 + overlord/hookstate/export_test.go | 46 + overlord/hookstate/hookmgr.go | 505 + overlord/hookstate/hooks.go | 111 + overlord/hookstate/hookstate.go | 48 + overlord/hookstate/hookstate_test.go | 1334 ++ overlord/hookstate/hooktest/handler.go | 78 + overlord/hookstate/hooktest/handler_test.go | 91 + overlord/hookstate/repository.go | 74 + overlord/hookstate/repository_test.go | 69 + overlord/ifacestate/export_test.go | 173 + overlord/ifacestate/handlers.go | 1766 +++ overlord/ifacestate/handlers_test.go | 66 + overlord/ifacestate/helpers.go | 1246 ++ overlord/ifacestate/helpers_test.go | 729 ++ overlord/ifacestate/hooks.go | 58 + overlord/ifacestate/hotplug.go | 455 + overlord/ifacestate/hotplug_test.go | 1178 ++ overlord/ifacestate/ifacemgr.go | 471 + overlord/ifacestate/ifacerepo/repo.go | 41 + overlord/ifacestate/ifacerepo/repo_test.go | 63 + overlord/ifacestate/ifacestate.go | 541 + overlord/ifacestate/ifacestate_test.go | 8819 +++++++++++++ overlord/ifacestate/implicit.go | 99 + overlord/ifacestate/implicit_test.go | 117 + overlord/ifacestate/udevmonitor/udevmon.go | 207 + .../ifacestate/udevmonitor/udevmon_test.go | 196 + overlord/managers_test.go | 5851 +++++++++ overlord/overlord.go | 701 + overlord/overlord_test.go | 1300 ++ overlord/patch/export_test.go | 90 + overlord/patch/patch.go | 214 + overlord/patch/patch1.go | 123 + overlord/patch/patch1_test.go | 158 + overlord/patch/patch2.go | 165 + overlord/patch/patch2_test.go | 179 + overlord/patch/patch3.go | 61 + overlord/patch/patch3_test.go | 149 + overlord/patch/patch4.go | 322 + overlord/patch/patch4_test.go | 451 + overlord/patch/patch5.go | 88 + overlord/patch/patch6.go | 115 + overlord/patch/patch6_1.go | 149 + overlord/patch/patch6_1_test.go | 284 + overlord/patch/patch6_2.go | 156 + overlord/patch/patch6_2_test.go | 374 + overlord/patch/patch6_3.go | 111 + overlord/patch/patch6_3_test.go | 354 + overlord/patch/patch6_test.go | 209 + overlord/patch/patch_test.go | 401 + overlord/servicestate/export_test.go | 24 + overlord/servicestate/helpers.go | 99 + overlord/servicestate/service_control.go | 165 + overlord/servicestate/service_control_test.go | 917 ++ overlord/servicestate/servicemgr.go | 61 + overlord/servicestate/servicestate.go | 283 + overlord/servicestate/servicestate_test.go | 165 + overlord/snapshotstate/backend/backend.go | 1020 ++ .../snapshotstate/backend/backend_test.go | 1540 +++ overlord/snapshotstate/backend/export_test.go | 146 + overlord/snapshotstate/backend/helpers.go | 258 + overlord/snapshotstate/backend/reader.go | 397 + .../snapshotstate/backend/restorestate.go | 96 + overlord/snapshotstate/export_test.go | 208 + overlord/snapshotstate/snapshotmgr.go | 423 + overlord/snapshotstate/snapshotmgr_test.go | 805 ++ overlord/snapshotstate/snapshotstate.go | 616 + overlord/snapshotstate/snapshotstate_test.go | 1923 +++ overlord/snapstate/aliasesv2.go | 704 + overlord/snapstate/aliasesv2_test.go | 1557 +++ overlord/snapstate/autorefresh.go | 623 + overlord/snapstate/autorefresh_test.go | 980 ++ overlord/snapstate/aux_store_info.go | 111 + overlord/snapstate/aux_store_info_test.go | 84 + overlord/snapstate/backend.go | 105 + overlord/snapstate/backend/aliases.go | 111 + overlord/snapstate/backend/aliases_test.go | 368 + overlord/snapstate/backend/backend.go | 58 + overlord/snapstate/backend/backend_test.go | 108 + overlord/snapstate/backend/copydata.go | 100 + overlord/snapstate/backend/copydata_test.go | 540 + overlord/snapstate/backend/export_test.go | 45 + overlord/snapstate/backend/fontconfig.go | 44 + overlord/snapstate/backend/link.go | 332 + overlord/snapstate/backend/link_test.go | 726 + overlord/snapstate/backend/locking.go | 93 + overlord/snapstate/backend/locking_test.go | 99 + overlord/snapstate/backend/mountns.go | 29 + overlord/snapstate/backend/mountunit.go | 46 + overlord/snapstate/backend/mountunit_test.go | 127 + overlord/snapstate/backend/setup.go | 163 + overlord/snapstate/backend/setup_test.go | 425 + overlord/snapstate/backend/snapdata.go | 261 + overlord/snapstate/backend/snapdata_test.go | 120 + overlord/snapstate/backend/utils.go | 30 + overlord/snapstate/backend_test.go | 1166 ++ overlord/snapstate/booted.go | 101 + overlord/snapstate/booted_test.go | 457 + overlord/snapstate/catalogrefresh.go | 186 + overlord/snapstate/catalogrefresh_test.go | 252 + overlord/snapstate/check_snap.go | 662 + overlord/snapstate/check_snap_test.go | 1307 ++ overlord/snapstate/conflict.go | 187 + overlord/snapstate/cookies.go | 174 + overlord/snapstate/cookies_test.go | 159 + overlord/snapstate/dbus.go | 86 + overlord/snapstate/dbus_test.go | 297 + overlord/snapstate/devicectx.go | 128 + overlord/snapstate/devicectx_test.go | 219 + overlord/snapstate/export_test.go | 304 + overlord/snapstate/flags.go | 104 + overlord/snapstate/handlers.go | 2961 +++++ overlord/snapstate/handlers_aliasesv2_test.go | 1952 +++ overlord/snapstate/handlers_discard_test.go | 177 + overlord/snapstate/handlers_download_test.go | 297 + overlord/snapstate/handlers_link_test.go | 1614 +++ overlord/snapstate/handlers_mount_test.go | 498 + overlord/snapstate/handlers_prepare_test.go | 116 + overlord/snapstate/handlers_prereq_test.go | 641 + overlord/snapstate/handlers_rerefresh_test.go | 371 + overlord/snapstate/handlers_test.go | 285 + overlord/snapstate/models_test.go | 99 + overlord/snapstate/policy.go | 33 + overlord/snapstate/policy/base.go | 118 + overlord/snapstate/policy/canremove_test.go | 436 + overlord/snapstate/policy/errors.go | 54 + overlord/snapstate/policy/export_test.go | 24 + overlord/snapstate/policy/gadget.go | 53 + overlord/snapstate/policy/kernel.go | 58 + overlord/snapstate/policy/os.go | 74 + overlord/snapstate/policy/policy.go | 83 + overlord/snapstate/policy/policy_test.go | 32 + overlord/snapstate/policy/snapd.go | 63 + overlord/snapstate/progress.go | 110 + overlord/snapstate/progress_test.go | 90 + overlord/snapstate/readme.go | 65 + overlord/snapstate/readme_test.go | 76 + overlord/snapstate/refresh.go | 231 + overlord/snapstate/refresh_test.go | 284 + overlord/snapstate/refreshhints.go | 123 + overlord/snapstate/refreshhints_test.go | 165 + overlord/snapstate/snapmgr.go | 886 ++ overlord/snapstate/snapstate.go | 2732 ++++ .../snapstate_config_defaults_test.go | 281 + overlord/snapstate/snapstate_install_test.go | 3618 +++++ overlord/snapstate/snapstate_remove_test.go | 1587 +++ overlord/snapstate/snapstate_test.go | 6452 +++++++++ overlord/snapstate/snapstate_try_test.go | 165 + overlord/snapstate/snapstate_update_test.go | 5703 ++++++++ overlord/snapstate/snapstatetest/devicectx.go | 149 + overlord/snapstate/storehelpers.go | 589 + overlord/snapstate/storehelpers_test.go | 347 + overlord/standby/export_test.go | 37 + overlord/standby/standby.go | 143 + overlord/standby/standby_test.go | 208 + overlord/state/change.go | 610 + overlord/state/change_test.go | 726 + overlord/state/copy.go | 144 + overlord/state/copy_test.go | 146 + overlord/state/export_test.go | 75 + overlord/state/state.go | 544 + overlord/state/state_test.go | 1052 ++ overlord/state/task.go | 566 + overlord/state/task_test.go | 635 + overlord/state/taskrunner.go | 536 + overlord/state/taskrunner_test.go | 981 ++ overlord/state/timings.go | 44 + overlord/state/timings_test.go | 102 + overlord/state/warning.go | 292 + overlord/state/warning_test.go | 267 + overlord/stateengine.go | 194 + overlord/stateengine_test.go | 181 + overlord/storecontext/context.go | 266 + overlord/storecontext/context_test.go | 472 + overlord/unknowntask.go | 34 + packaging/amzn-2 | 1 + packaging/arch/PKGBUILD | 217 + packaging/arch/snapd.install | 49 + packaging/build-tools/go | 16 + packaging/centos-7 | 1 + packaging/centos-8 | 1 + packaging/debian-sid/README.Source | 35 + packaging/debian-sid/changelog | 6544 ++++++++++ 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 | 6 + ...seccomp-use-upstream-seccomp-package.patch | 78 + ...seccomp-skip-tests-that-fail-on-4.19.patch | 46 + ...snap-seccomp-skip-tests-that-use-m32.patch | 45 + ...kip-tests-depending-on-text-wrapping.patch | 132 + ...errtracker-use-upstream-bolt-package.patch | 49 + ...0006-systemd-disable-snapfuse-system.patch | 30 + ...-localizations-to-avoid-dependencies.patch | 285 + .../patches/0010-man-page-sections.patch | 22 + packaging/debian-sid/patches/series | 8 + packaging/debian-sid/rules | 294 + 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 | 40 + packaging/debian-sid/snapd.links | 6 + packaging/debian-sid/snapd.lintian-overrides | 13 + packaging/debian-sid/snapd.maintscript | 5 + packaging/debian-sid/snapd.manpages | 1 + packaging/debian-sid/snapd.postinst | 41 + packaging/debian-sid/snapd.postrm | 145 + 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-29 | 1 + packaging/fedora-30 | 1 + packaging/fedora-31 | 1 + packaging/fedora-32 | 1 + packaging/fedora-33 | 1 + packaging/fedora-rawhide | 1 + packaging/fedora/snapd.spec | 8629 ++++++++++++ packaging/opensuse-15.0 | 1 + packaging/opensuse-15.1 | 1 + packaging/opensuse-15.2 | 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 | 530 + packaging/opensuse/snapd.spec | 462 + packaging/pack-source | 63 + packaging/snapd.mk | 190 + packaging/ubuntu-14.04/changelog | 10860 +++++++++++++++ packaging/ubuntu-14.04/compat | 1 + packaging/ubuntu-14.04/control | 134 + packaging/ubuntu-14.04/copyright | 22 + .../golang-github-snapcore-snapd-dev.install | 1 + packaging/ubuntu-14.04/rules | 232 + .../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 | 17 + packaging/ubuntu-14.04/snapd.install | 43 + packaging/ubuntu-14.04/snapd.links | 2 + packaging/ubuntu-14.04/snapd.maintscript | 5 + packaging/ubuntu-14.04/snapd.manpages | 1 + packaging/ubuntu-14.04/snapd.postinst | 34 + packaging/ubuntu-14.04/snapd.postrm | 145 + 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 | 10898 ++++++++++++++++ packaging/ubuntu-16.04/compat | 1 + packaging/ubuntu-16.04/control | 130 + 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 | 285 + .../ubuntu-16.04/snap-confine.maintscript | 1 + packaging/ubuntu-16.04/snapd.autoimport.udev | 3 + packaging/ubuntu-16.04/snapd.dirs | 23 + packaging/ubuntu-16.04/snapd.install | 53 + packaging/ubuntu-16.04/snapd.links | 6 + packaging/ubuntu-16.04/snapd.maintscript | 5 + packaging/ubuntu-16.04/snapd.manpages | 1 + packaging/ubuntu-16.04/snapd.postinst | 75 + packaging/ubuntu-16.04/snapd.postrm | 155 + 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 | 85 + 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 | 68 + polkit/pid_start_time_test.go | 71 + progress/ansimeter.go | 213 + progress/ansimeter_test.go | 286 + progress/export_test.go | 110 + progress/progress.go | 106 + progress/progress_test.go | 65 + progress/progresstest/progresstest.go | 68 + randutil/crypto.go | 61 + randutil/crypto_test.go | 47 + randutil/rand.go | 95 + randutil/rand_test.go | 62 + release-tools/debian-package-builder | 125 + release-tools/repack-debian-tarball.sh | 92 + release/export_test.go | 47 + release/release.go | 161 + release/release_test.go | 191 + run-checks | 341 + sandbox/apparmor/apparmor.go | 414 + sandbox/apparmor/apparmor_test.go | 305 + sandbox/apparmor/export_test.go | 52 + sandbox/apparmor/process.go | 76 + sandbox/apparmor/process_test.go | 134 + sandbox/cgroup/cgroup.go | 334 + sandbox/cgroup/cgroup_test.go | 299 + sandbox/cgroup/export_test.go | 99 + sandbox/cgroup/freezer.go | 115 + sandbox/cgroup/freezer_test.go | 92 + sandbox/cgroup/pids.go | 68 + sandbox/cgroup/pids_test.go | 47 + sandbox/cgroup/process.go | 68 + sandbox/cgroup/process_test.go | 81 + sandbox/cgroup/scanning.go | 162 + sandbox/cgroup/scanning_test.go | 224 + sandbox/cgroup/tracking.go | 315 + sandbox/cgroup/tracking_test.go | 631 + sandbox/forcedevmode.go | 54 + sandbox/forcedevmode_test.go | 71 + 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 + sanity/apparmor_lxd.go | 51 + sanity/apparmor_lxd_test.go | 40 + sanity/cgroup.go | 42 + sanity/cgroup_test.go | 51 + sanity/check.go | 32 + sanity/check_test.go | 132 + sanity/export_test.go | 58 + sanity/squashfs.go | 142 + sanity/squashfs_test.go | 149 + sanity/version.go | 102 + sanity/version_test.go | 167 + sanity/wsl.go | 38 + sanity/wsl_test.go | 51 + secboot/encrypt.go | 101 + secboot/encrypt_dummy.go | 25 + secboot/encrypt_test.go | 83 + secboot/encrypt_tpm.go | 57 + secboot/encrypt_tpm_test.go | 105 + secboot/export_test.go | 213 + secboot/secboot.go | 152 + secboot/secboot_dummy.go | 37 + secboot/secboot_tpm.go | 867 ++ secboot/secboot_tpm_test.go | 1447 ++ seed/export_test.go | 30 + seed/helpers.go | 152 + seed/helpers_test.go | 167 + seed/internal/auxinfo20.go | 26 + 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/internal/validate.go | 41 + seed/internal/validate_test.go | 68 + seed/seed.go | 166 + seed/seed16.go | 329 + seed/seed16_test.go | 1138 ++ seed/seed20.go | 509 + seed/seed20_test.go | 1849 +++ seed/seedtest/sample.go | 130 + seed/seedtest/seedtest.go | 326 + seed/seedwriter/export_test.go | 34 + seed/seedwriter/helpers.go | 171 + seed/seedwriter/seed16.go | 261 + seed/seedwriter/seed20.go | 393 + seed/seedwriter/writer.go | 1155 ++ seed/seedwriter/writer_test.go | 3203 +++++ seed/validate.go | 137 + seed/validate_test.go | 405 + snap/broken.go | 119 + snap/broken_test.go | 151 + snap/channel/channel.go | 297 + snap/channel/channel_test.go | 393 + snap/container.go | 253 + snap/container_test.go | 391 + snap/epoch.go | 374 + snap/epoch_test.go | 375 + snap/errors.go | 50 + snap/export_test.go | 35 + snap/helpers.go | 28 + snap/hooktypes.go | 80 + snap/hotplug_key.go | 34 + snap/hotplug_key_test.go | 45 + snap/implicit.go | 83 + snap/implicit_test.go | 28 + snap/info.go | 1392 ++ snap/info_snap_yaml.go | 727 ++ snap/info_snap_yaml_test.go | 2088 +++ snap/info_test.go | 1785 +++ snap/internal/file.go | 42 + 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 | 187 + snap/naming/validate_test.go | 326 + snap/naming/wellknown.go | 67 + snap/naming/wellknown_test.go | 53 + snap/pack/export_test.go | 24 + snap/pack/pack.go | 245 + snap/pack/pack_test.go | 355 + snap/restartcond.go | 77 + snap/restartcond_test.go | 51 + snap/revision.go | 123 + snap/revision_test.go | 199 + snap/snapdir/snapdir.go | 205 + snap/snapdir/snapdir_test.go | 209 + snap/snapenv/snapenv.go | 124 + snap/snapenv/snapenv_test.go | 289 + snap/snapfile/snapfile.go | 57 + snap/snapfile/snapfile_test.go | 148 + snap/snaptest/snaptest.go | 306 + snap/snaptest/snaptest_test.go | 245 + snap/squashfs/export_test.go | 90 + snap/squashfs/squashfs.go | 573 + snap/squashfs/squashfs_test.go | 849 ++ snap/squashfs/stat.go | 370 + snap/squashfs/stat_test.go | 299 + snap/types.go | 184 + snap/types_test.go | 292 + snap/validate.go | 1121 ++ snap/validate_test.go | 2019 +++ snapdenv/export_test.go | 33 + snapdenv/snapdenv.go | 94 + snapdenv/snapdenv_test.go | 146 + snapdenv/useragent.go | 88 + snapdenv/useragent_test.go | 92 + snapdenv/withtestkeys.go | 26 + snapdtool/cmdutil.go | 140 + snapdtool/cmdutil_test.go | 114 + snapdtool/export_test.go | 52 + snapdtool/info_file.go | 53 + snapdtool/info_file_test.go | 57 + snapdtool/tool_linux.go | 227 + snapdtool/tool_linux_test.go | 118 + snapdtool/tool_other.go | 49 + snapdtool/tool_test.go | 451 + snapdtool/version.go | 34 + spdx/licenses.go | 630 + spdx/parser.go | 151 + spdx/parser_test.go | 78 + spdx/scanner.go | 69 + spdx/scanner_test.go | 49 + spdx/validate.go | 34 + spread-shellcheck | 292 + spread.yaml | 1014 ++ store/auth.go | 321 + store/auth_test.go | 435 + store/cache.go | 218 + store/cache_test.go | 217 + store/details.go | 122 + store/details_v2.go | 325 + store/details_v2_test.go | 421 + store/devicenauthctx.go | 68 + store/download_test.go | 564 + store/errors.go | 291 + store/export_test.go | 230 + store/search_v2.go | 53 + store/store.go | 1709 +++ store/store_action.go | 634 + store/store_action_fetch_assertions_test.go | 442 + store/store_action_test.go | 3082 +++++ store/store_asserts.go | 174 + store/store_asserts_test.go | 428 + store/store_download.go | 721 + store/store_download_test.go | 1042 ++ store/store_test.go | 4101 ++++++ store/storetest/storetest.go | 110 + store/stringlist_test.go | 41 + store/uacontext.go | 42 + store/uacontext_test.go | 47 + store/userinfo.go | 78 + store/userinfo_test.go | 152 + strutil/chrorder.go | 21 + strutil/chrorder/main.go | 74 + strutil/ctrl16.go | 51 + strutil/ctrl17.go | 52 + strutil/limbuffer.go | 51 + strutil/limbuffer_test.go | 67 + strutil/map.go | 121 + strutil/map_test.go | 96 + strutil/matchcounter.go | 103 + strutil/matchcounter_benchmark_test.go | 52 + strutil/matchcounter_test.go | 207 + strutil/pathiter.go | 143 + strutil/pathiter_test.go | 264 + strutil/quantity/example_test.go | 117 + strutil/quantity/quantity.go | 202 + strutil/set.go | 81 + strutil/set_test.go | 79 + strutil/shlex/shlex.go | 416 + strutil/shlex/shlex_test.go | 159 + strutil/strutil.go | 248 + strutil/strutil_test.go | 251 + strutil/version.go | 196 + strutil/version_benchmark_test.go | 111 + strutil/version_test.go | 128 + sysconfig/cloudinit.go | 388 + sysconfig/cloudinit_test.go | 578 + sysconfig/gadget_defaults_test.go | 157 + sysconfig/sysconfig.go | 122 + systemd/emulation.go | 186 + systemd/escape.go | 71 + 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 | 61 + systemd/sysctl_test.go | 89 + systemd/systemd.go | 935 ++ systemd/systemd_test.go | 1180 ++ tests/bin/MATCH | 1 + tests/bin/NOMATCH | 8 + tests/bin/README | 4 + tests/bin/REBOOT | 1 + tests/bin/any-python | 1 + tests/bin/mountinfo.query | 1 + tests/bin/not | 1 + tests/bin/os.query | 1 + tests/bin/retry | 1 + tests/bin/snapd.tool | 1 + tests/bin/tests.backup | 1 + tests/bin/tests.cleanup | 1 + tests/bin/tests.invariant | 1 + tests/bin/tests.pkgs | 1 + tests/bin/tests.session | 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 + .../data/twisted/.just a hidden file | 0 .../this is a file with spaces in it.doc | 0 .../completion/data/twisted/this isn't.innit | 0 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 | 28 + 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 | 23 + tests/completion/twisted.complete | 4 + tests/completion/twisted.sh | 1 + tests/completion/twisted.vars | 7 + tests/core/apt/task.yaml | 14 + tests/core/backlight/task.yaml | 21 + tests/core/basic18/task.yaml | 27 + tests/core/basic20/task.yaml | 80 + tests/core/classic-snap16/task.yaml | 61 + tests/core/compat/task.yaml | 11 + .../config-defaults-once/gadget-defaults.yaml | 7 + tests/core/config-defaults-once/task.yaml | 142 + tests/core/core-to-snapd-failover16/task.yaml | 60 + tests/core/create-user-2/task.yaml | 53 + tests/core/create-user/task.yaml | 55 + .../custom-device-reg-extras/prepare-device | 5 + tests/core/custom-device-reg-extras/task.yaml | 91 + tests/core/custom-device-reg/prepare-device | 2 + tests/core/custom-device-reg/task.yaml | 89 + tests/core/device-reg/task.yaml | 48 + .../core/enable-disable-units-gpio/task.yaml | 71 + tests/core/failover/task.yaml | 139 + tests/core/fan/task.yaml | 22 + tests/core/fsck-on-boot/task.yaml | 120 + tests/core/fsck-vfat/task.yaml | 67 + .../gadget-vitality-hint.yaml | 4 + .../gadget-config-defaults-vitality/task.yaml | 139 + .../gadget-rsyslog.yaml | 8 + .../gadget-ssh-common.yaml | 8 + .../gadget-ssh-oneline.yaml | 6 + tests/core/gadget-config-defaults/task.yaml | 178 + tests/core/gadget-update-pc/generate.py | 205 + tests/core/gadget-update-pc/task.yaml | 216 + tests/core/generic-device-reg/task.yaml | 66 + tests/core/grub-no-unpacked-assets/task.yaml | 15 + tests/core/iio/task.yaml | 48 + .../kernel-snap-refresh-on-core/task.yaml | 106 + tests/core/kernel-ver/task.yaml | 15 + tests/core/netplan/task.yaml | 89 + tests/core/network-config/task.yaml | 28 + tests/core/os-release/task.yaml | 17 + tests/core/persistent-journal/task.yaml | 53 + tests/core/reboot/task.yaml | 33 + tests/core/remodel-base/task.yaml | 136 + tests/core/remodel-gadget/task.yaml | 146 + tests/core/remodel-kernel/task.yaml | 168 + tests/core/remodel/task.yaml | 117 + tests/core/remove-user/task.yaml | 78 + tests/core/remove/task.yaml | 39 + tests/core/seed-base-symlinks/task.yaml | 23 + 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 | 66 + 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 | 38 + tests/core/snap-set-core-config/task.yaml | 130 + tests/core/snapd-failover/task.yaml | 163 + tests/core/snapd-refresh/task.yaml | 55 + tests/core/snapd16/task.yaml | 75 + tests/core/swapfiles/task.yaml | 30 + tests/core/system-settings/task.yaml | 22 + tests/core/system-snap-refresh/task.yaml | 93 + tests/core/uboot-unpacked-assets/task.yaml | 23 + tests/core/uc20-recovery/mock-shutdown | 10 + tests/core/uc20-recovery/task.yaml | 207 + tests/core/upgrade/task.yaml | 106 + tests/core/watchdog/task.yaml | 42 + tests/core/writablepaths/task.yaml | 48 + tests/cross/go-build/task.yaml | 63 + tests/external-backend.md | 37 + tests/go.mod | 0 ...WPhspDQK63Er46Uxz2SO7ez.auto-import.assert | 76 + ...DQK63Er46Uxz2SO7ez.auto-import.assert.json | 13 + tests/lib/assertions/README.md | 19 + tests/lib/assertions/auto-import.assert | 78 + tests/lib/assertions/auto-import.assert.json | 13 + .../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 + .../assertions/developer1-auto-import.assert | 77 + .../assertions/developer1-auto-import.json | 18 + .../developer1-my-classic-w-gadget-18.model | 22 + .../developer1-my-classic-w-gadget.model | 20 + .../assertions/developer1-my-classic.model | 19 + 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/nested-18-amd64.model | 24 + .../lib/assertions/nested-18-amd64.model.json | 13 + tests/lib/assertions/nested-20-amd64.model | 44 + .../lib/assertions/nested-20-amd64.model.json | 18 + tests/lib/assertions/nested-amd64.model | 21 + tests/lib/assertions/nested-amd64.model.json | 11 + tests/lib/assertions/nested-i386.model | 21 + tests/lib/assertions/nested-i386.model.json | 11 + .../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 + .../assertions/testrootorg-store.account-key | 30 + .../lib/assertions/ubuntu-core-18-amd64.model | 23 + .../lib/assertions/ubuntu-core-20-amd64.model | 42 + tests/lib/best_golang.py | 18 + tests/lib/cache/README.txt | 3 + tests/lib/changes.sh | 8 + tests/lib/cla_check.py | 153 + .../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 | 100 + tests/lib/desktop-portal.sh | 56 + tests/lib/dirs.sh | 27 + tests/lib/disabled-svcs.sh | 54 + tests/lib/ensure_ubuntu_save.py | 71 + tests/lib/external/prepare-ssh.sh | 19 + 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 | 39 + .../cmd/fakestore/cmd_new_snap_decl.go | 63 + .../cmd/fakestore/cmd_new_snap_rev.go | 63 + tests/lib/fakestore/cmd/fakestore/cmd_run.go | 70 + tests/lib/fakestore/cmd/fakestore/main.go | 51 + tests/lib/fakestore/refresh/refresh.go | 258 + tests/lib/fakestore/refresh/snap_asserts.go | 95 + tests/lib/fakestore/store/store.go | 730 ++ tests/lib/fakestore/store/store_test.go | 624 + tests/lib/fde-setup-hook/fde-setup.go | 112 + tests/lib/gendeveloper1model/main.go | 66 + tests/lib/hotplug.sh | 110 + tests/lib/list-interfaces.go | 10 + tests/lib/manip_seed.py | 29 + tests/lib/mkpinentry.sh | 21 + tests/lib/names.sh | 10 + tests/lib/nested.sh | 1244 ++ tests/lib/network.sh | 28 + tests/lib/os-release.16 | 7 + tests/lib/pinentry-fake.sh | 20 + tests/lib/pkgdb.sh | 827 ++ tests/lib/prepare-restore.sh | 825 ++ tests/lib/prepare.sh | 1121 ++ tests/lib/preseed.sh | 92 + tests/lib/quiet.sh | 30 + tests/lib/ramdisk.sh | 8 + tests/lib/random.sh | 39 + tests/lib/reset.sh | 198 + tests/lib/snaps.sh | 169 + .../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 + 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 + .../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/lib/snaps/basic-run/bin/echo | 3 + tests/lib/snaps/basic-run/meta/snap.yaml | 6 + tests/lib/snaps/basic/meta/snap.yaml | 4 + tests/lib/snaps/basic18/meta/snap.yaml | 5 + .../snaps/browser-support-consumer/bin/cmd | 3 + .../meta/snap.yaml.in | 10 + .../snaps/classic-gadget-18/meta/gadget.yaml | 1 + .../meta/hooks/prepare-device | 2 + .../snaps/classic-gadget-18/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/command-chain/chain1 | 6 + tests/lib/snaps/command-chain/chain2 | 6 + tests/lib/snaps/command-chain/chain3 | 5 + tests/lib/snaps/command-chain/chain4 | 5 + tests/lib/snaps/command-chain/hello | 3 + .../snaps/command-chain/meta/hooks/configure | 4 + tests/lib/snaps/command-chain/meta/snap.yaml | 17 + tests/lib/snaps/command-chain/run | 6 + 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 + tests/lib/snaps/data-writer/bin/write-data | 20 + tests/lib/snaps/data-writer/meta/snap.yaml | 9 + .../lib/snaps/disabled-svcs-kept/bin/service | 2 + .../disabled-svcs-kept/meta/hooks/configure | 5 + .../disabled-svcs-kept/meta/snap.yaml.in | 6 + .../failing-config-hooks/meta/hooks/configure | 4 + .../snaps/failing-config-hooks/meta/snap.yaml | 2 + .../firewall-control-consumer/bin/consumer | 3 + .../firewall-control-consumer/meta/snap.yaml | 12 + 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 + .../hardware-observe-consumer/bin/consumer | 5 + .../hardware-observe-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/iio-consumer/bin/read | 2 + tests/lib/snaps/iio-consumer/bin/write | 2 + tests/lib/snaps/iio-consumer/meta/snap.yaml | 12 + .../lib/snaps/locale-control-consumer/bin/get | 14 + .../lib/snaps/locale-control-consumer/bin/set | 23 + .../locale-control-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/modem-manager-consumer/bin/consumer | 3 + .../modem-manager-consumer/meta/snap.yaml | 9 + .../snaps/mount-observe-consumer/bin/consumer | 12 + .../mount-observe-consumer/meta/snap.yaml | 9 + .../snaps/network-bind-consumer/bin/consumer | 22 + .../network-bind-consumer/meta/snap.yaml | 10 + tests/lib/snaps/network-consumer/bin/consumer | 21 + .../lib/snaps/network-consumer/meta/snap.yaml | 9 + .../snaps/network-control-consumer/bin/cmd | 6 + .../network-control-consumer/meta/snap.yaml | 9 + .../network-observe-consumer/bin/consumer | 10 + .../network-observe-consumer/meta/snap.yaml | 9 + .../snaps/process-control-consumer/bin/signal | 6 + .../process-control-consumer/meta/snap.yaml | 9 + .../snaps/serial-port-hotplug/bin/consumer | 15 + .../snaps/serial-port-hotplug/meta/snap.yaml | 9 + .../bin/consumer | 5 + .../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-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 + .../snaps/snapctl-from-snap/bin/snapctl-get | 2 + .../snaps/snapctl-from-snap/bin/snapctl-set | 2 + .../snapctl-from-snap/meta/hooks/configure | 3 + .../snaps/snapctl-from-snap/meta/snap.yaml | 7 + .../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 + .../lib/snaps/test-classic-cgroup/bin/read-fb | 3 + .../snaps/test-classic-cgroup/bin/read-kmsg | 3 + .../snaps/test-classic-cgroup/meta/snap.yaml | 12 + .../snaps/test-devmode-cgroup/bin/read-dev | 4 + .../snaps/test-devmode-cgroup/meta/snap.yaml | 11 + .../list-accounts.c | 69 + .../snapcraft.yaml | 25 + tests/lib/snaps/test-snapd-adb-support/bin/sh | 3 + .../test-snapd-adb-support/meta/snap.yaml | 7 + .../test-snapd-after-before-service/bin/start | 11 + .../meta/snap.yaml | 19 + .../test-snapd-appstream-metadata/bin/sh | 3 + .../meta/snap.yaml | 8 + .../lib/snaps/test-snapd-appstreamid/bin/run | 3 + .../test-snapd-appstreamid/meta/snap.yaml | 14 + .../snaps/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-auto-aliases/bin/wellknown1 | 2 + .../test-snapd-auto-aliases/bin/wellknown2 | 2 + .../test-snapd-auto-aliases/meta/snap.yaml | 9 + .../test-snapd-autopilot-consumer/consumer | 22 + .../test-snapd-autopilot-consumer/provider.py | 38 + .../snapcraft.yaml | 28 + .../test-snapd-autopilot-consumer/wrapper | 3 + tests/lib/snaps/test-snapd-base-bare/Makefile | 13 + .../snaps/test-snapd-base-bare/snapcraft.yaml | 12 + .../test-snapd-base-none-invalid/bin/cmd | 2 + .../meta/snap.yaml | 8 + .../snaps/test-snapd-base-none/meta/snap.yaml | 4 + .../lib/snaps/test-snapd-base/meta/snap.yaml | 4 + tests/lib/snaps/test-snapd-base/random-file | 1 + .../test-snapd-busybox-static/snapcraft.yaml | 17 + .../bin/classic-confinement | 4 + .../bin/recurse | 6 + .../test-snapd-classic-confinement/bin/sh | 3 + .../meta/snap.yaml | 10 + .../bin/service | 8 + .../meta/hooks/configure | 3 + .../meta/hooks/install | 3 + .../meta/snap.yaml | 9 + .../snaps/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 + .../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 + .../bin/content-plug | 6 + .../import/.placeholder | 0 .../meta/snap.yaml | 18 + .../bin/content-plug | 6 + .../import/.placeholder | 0 .../meta/snap.yaml | 18 + .../test-snapd-content-mimic-plug/bin/sh | 3 + .../dir/stuff-in-dir | 0 .../snaps/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 + .../bin/content-plug | 11 + .../import/.placeholder | 0 .../meta/snap.yaml | 11 + .../test-snapd-content-plug/bin/content-plug | 11 + .../import/.placeholder | 0 .../test-snapd-content-plug/meta/snap.yaml | 12 + .../meta/snap.yaml | 7 + .../shared-content | 3 + .../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 + .../bin/sh | 3 + .../meta/snap.yaml | 7 + .../bin/sh | 3 + .../meta/snap.yaml | 6 + .../snapcraft.yaml | 13 + .../snaps/test-snapd-daemon-notify/bin/notify | 3 + .../test-snapd-daemon-notify/meta/snap.yaml | 10 + .../lib/snaps/test-snapd-daemon-user/Makefile | 8 + .../test-snapd-daemon-user/snapcraft.yaml | 98 + .../snaps/test-snapd-daemon-user/src/Makefile | 263 + .../snaps/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 + .../snaps/test-snapd-daemon-user/src/drop.c | 68 + .../snaps/test-snapd-daemon-user/src/drop32.c | 1 + .../snaps/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 + .../snaps/test-snapd-daemon-user/src/lchown.c | 58 + .../test-snapd-daemon-user/src/lchown32.c | 1 + .../snaps/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 + .../snaps/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 + .../snaps/test-snapd-dbus-provider/wrapper | 8 + .../bin/client.sh | 5 + .../meta/snap.yaml | 22 + .../bin/server.sh | 2 + .../meta/snap.yaml | 16 + .../bin/test-snapd-dbus-service | 48 + .../snaps/test-snapd-dbus-service/setup.py | 11 + .../test-snapd-dbus-service/snapcraft.yaml | 44 + .../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 tests/lib/snaps/test-snapd-devpts/bin/openpty | 18 + tests/lib/snaps/test-snapd-devpts/bin/useptmx | 20 + .../snaps/test-snapd-devpts/meta/snap.yaml | 9 + tests/lib/snaps/test-snapd-eds/calendar.c | 201 + tests/lib/snaps/test-snapd-eds/contacts.c | 222 + tests/lib/snaps/test-snapd-eds/meson.build | 14 + .../snaps/test-snapd-eds/snap/snapcraft.yaml | 47 + .../snaps/test-snapd-epoch-1/meta/snap.yaml | 5 + .../snaps/test-snapd-epoch-2/meta/snap.yaml | 5 + .../test-snapd-event/bin/read-evdev-device | 29 + .../lib/snaps/test-snapd-event/meta/snap.yaml | 12 + .../lib/snaps/test-snapd-framebuffer/bin/read | 3 + .../snaps/test-snapd-framebuffer/bin/write | 3 + .../test-snapd-framebuffer/meta/snap.yaml | 12 + .../snaps/test-snapd-fuse-consumer/Makefile | 9 + .../test-snapd-fuse-consumer/snapcraft.yaml | 18 + .../snaps/test-snapd-fwupd/bin/get-version.sh | 6 + .../lib/snaps/test-snapd-fwupd/meta/snap.yaml | 9 + .../lib/snaps/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 + .../bin/check | 5 + .../meta/snap.yaml | 10 + .../bin/check | 5 + .../meta/snap.yaml | 10 + tests/lib/snaps/test-snapd-health/health | 3 + .../test-snapd-health/meta/hooks/check-health | 3 + .../test-snapd-health/meta/hooks/configure | 9 + .../snaps/test-snapd-health/meta/snap.yaml | 5 + .../snaps/test-snapd-hello-classic/Makefile | 9 + .../test-snapd-hello-classic/snapcraft.yaml | 16 + .../test-snapd-hello-classic.c | 12 + .../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 + .../test-snapd-illegal-system-username/bin/sh | 3 + .../meta/snap.yaml | 10 + .../test-snapd-invalid-base/meta/snap.yaml | 4 + .../lib/snaps/test-snapd-just-beta/snap-name | 3 + .../snaps/test-snapd-just-beta/snapcraft.yaml | 15 + .../lib/snaps/test-snapd-just-edge/snap-name | 3 + .../snaps/test-snapd-just-edge/snapcraft.yaml | 15 + .../snapcraft.yaml | 24 + 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 + .../bin/machine-down | 3 + .../bin/machine-up | 3 + .../snapcraft.yaml | 49 + .../vm/ping-unikernel.xml | 22 + .../consumer | 22 + .../provider.py | 29 + .../snapcraft.yaml | 26 + .../wrapper | 3 + tests/lib/snaps/test-snapd-lp-1803535/bin/sh | 3 + .../etc/OpenCL/vendors/foo.icd | 1 + .../test-snapd-lp-1803535/meta/snap.yaml | 9 + .../snaps/test-snapd-mokutil/snapcraft.yaml | 23 + .../snaps/test-snapd-multi-service/bin/start | 6 + .../test-snapd-multi-service/meta/snap.yaml | 9 + .../snaps/test-snapd-netlink-audit/bin/bind | 15 + .../test-snapd-netlink-audit/meta/snap.yaml | 9 + .../test-snapd-netlink-connector/bin/bind | 15 + .../meta/snap.yaml | 9 + .../bin/get-connectivity.sh | 5 + .../meta/snap.yaml | 9 + .../test-snapd-number-version/meta/snap.yaml | 3 + .../bin/ovs-vsctl | 3 + .../snapcraft.yaml | 21 + .../test-snapd-packagekit/snapcraft.yaml | 31 + .../bin/secret-tool | 3 + .../snapcraft.yaml | 21 + .../bin/head-mem | 3 + .../meta/snap.yaml | 9 + .../test-snapd-policy-app-consumer/bin/run | 10 + .../meta/gui/test-desktop.desktop | 6 + .../meta/snap.yaml | 469 + .../bin/run | 10 + .../meta/snap.yaml | 94 + .../bin/run | 10 + .../meta/snap.yaml | 155 + .../snaps/test-snapd-portal-client/client.py | 146 + .../snaps/test-snapd-portal-client/setup.py | 7 + .../test-snapd-portal-client/snapcraft.yaml | 20 + .../snaps/test-snapd-private/meta/snap.yaml | 6 + .../test-snapd-profiler-core18/config.ini | 7 + .../test-snapd-profiler-core18/profiler.py | 95 + .../test-snapd-profiler-core18/snapcraft.yaml | 24 + .../lib/snaps/test-snapd-profiler/config.ini | 7 + .../lib/snaps/test-snapd-profiler/profiler.py | 95 + .../snaps/test-snapd-profiler/snapcraft.yaml | 23 + .../snaps/test-snapd-public/meta/snap.yaml | 6 + .../lib/snaps/test-snapd-pulseaudio/Makefile | 2 + .../test-snapd-pulseaudio/files/bin/pawrap | 16 + .../test-snapd-pulseaudio/snapcraft.yaml | 79 + .../snaps/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 + .../meta/snap.yaml | 4 + .../test-snapd-requires-base/meta/snap.yaml | 4 + .../lib/snaps/test-snapd-rsync/snapcraft.yaml | 16 + .../forking.sh | 6 + .../meta/snap.yaml | 7 + .../forking.sh | 11 + .../meta/snap.yaml | 8 + .../test-snapd-service-stop-timeout/staaap.sh | 5 + .../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 + .../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 + .../test-snapd-service-watchdog/bin/direct | 58 + .../meta/snap.yaml | 15 + .../snaps/test-snapd-service-writer/bin/start | 21 + .../meta/hooks/configure | 16 + .../test-snapd-service-writer/meta/snap.yaml | 10 + 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 | 59 + .../lib/snaps/test-snapd-setpriority/Makefile | 5 + .../test-snapd-setpriority/setpriority.c | 35 + .../test-snapd-setpriority/snapcraft.yaml | 16 + 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/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 + .../meta/hooks/configure | 3 + .../test-snapd-sleep-install/meta/snap.yaml | 3 + .../test-snapd-snapctl-core18/bin/service | 5 + .../meta/hooks/install | 3 + .../test-snapd-snapctl-core18/meta/snap.yaml | 10 + tests/lib/snaps/test-snapd-statx/bin/statx.py | 100 + .../lib/snaps/test-snapd-statx/meta/snap.yaml | 7 + .../bin/forking.sh | 3 + .../bin/simple.sh | 3 + .../meta/hooks/install | 6 + .../meta/snap.yaml | 9 + .../bin/forking.sh | 3 + .../bin/simple.sh | 3 + .../meta/hooks/post-refresh | 6 + .../meta/snap.yaml | 9 + .../consumer.py | 13 + .../dbus-introspect.py | 14 + .../snapcraft.yaml | 25 + .../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 + 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 + .../lib/snaps/test-snapd-tuntap/bin/tuntap.py | 79 + .../snaps/test-snapd-tuntap/meta/snap.yaml | 10 + .../bin/read-evdev-kbd | 24 + .../meta/snap.yaml | 24 + .../snaps/test-snapd-udisks2/snapcraft.yaml | 17 + tests/lib/snaps/test-snapd-udisks2/udisksctl | 3 + tests/lib/snaps/test-snapd-uhid/Makefile | 5 + .../lib/snaps/test-snapd-uhid/snapcraft.yaml | 17 + tests/lib/snaps/test-snapd-uhid/uhid-test.c | 190 + .../test-snapd-unknown-interfaces/bin/sh | 3 + .../meta/snap.yaml | 10 + .../snapcraft.yaml | 14 + .../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 + .../bin/bar | 0 .../bin/foo | 0 .../comp.sh | 0 .../hell/bar | 1 + .../hell/bar -> baz | 1 + .../hell/bar -> baz -> qux | 1 + .../hell/bar -> qux | 1 + .../hell/baz | 1 + .../hell/baz -> qux | 1 + .../hell/foo | 1 + .../hell/foo -> bar | 1 + .../hell/foo -> bar -> baz | 1 + .../hell/foo -> bar -> qux | 1 + .../hell/foo -> baz | 1 + .../hell/foo -> baz -> qux | 1 + .../hell/foo -> qux | 1 + .../hell/qux | 1 + .../meta/hooks/what | 0 .../meta/snap.yaml | 11 + .../meta/unreadable | 0 .../meta/hooks/configure | 3 + .../meta/snap.yaml | 10 + .../test-snapd-with-configure-core18/service | 4 + .../snapcraft.yaml | 10 + .../meta/hooks/configure | 6 + .../meta/snap.yaml | 8 + .../meta/hooks/configure | 2 + .../test-snapd-with-configure/meta/snap.yaml | 9 + .../snaps/test-snapd-with-configure/service | 4 + .../snaps/test-snapd-xdg-autostart/bin/foobar | 26 + .../test-snapd-xdg-autostart/meta/snap.yaml | 6 + .../snaps/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 + .../lib/snaps/test-strict-cgroup/bin/read-dev | 4 + .../snaps/test-strict-cgroup/meta/snap.yaml | 11 + tests/lib/spread-funcs.sh | 14 + tests/lib/state.sh | 134 + tests/lib/store.sh | 162 + tests/lib/successful_login.exp | 13 + tests/lib/systemd-escape/main.go | 52 + tests/lib/systemd.sh | 69 + tests/lib/systems.sh | 35 + tests/lib/tinyproxy/tinyproxy.py | 136 + tests/lib/tools/MATCH | 8 + tests/lib/tools/README | 116 + tests/lib/tools/REBOOT | 8 + tests/lib/tools/any-python | 17 + tests/lib/tools/apt-state | 64 + tests/lib/tools/boot-state | 200 + tests/lib/tools/cleanup-state | 99 + tests/lib/tools/fs-state | 104 + tests/lib/tools/journal-state | 118 + tests/lib/tools/lxd-state | 53 + tests/lib/tools/memory-observe-do | 84 + tests/lib/tools/mountinfo.query | 1325 ++ tests/lib/tools/nested-state | 148 + tests/lib/tools/not | 6 + tests/lib/tools/os.query | 146 + tests/lib/tools/retry | 133 + tests/lib/tools/sha3-384 | 22 + tests/lib/tools/snapd.tool | 55 + tests/lib/tools/snaps-state | 93 + tests/lib/tools/suite/apt-state/task.yaml | 10 + tests/lib/tools/suite/fs-state/task.yaml | 80 + tests/lib/tools/suite/journal-state/task.yaml | 39 + .../lib/tools/suite/mountinfo.query/task.yaml | 5 + tests/lib/tools/suite/retry-tool/task.yaml | 15 + tests/lib/tools/suite/tests.backup/task.yaml | 67 + tests/lib/tools/suite/tests.cleanup/task.yaml | 98 + .../lib/tools/suite/tests.invariant/task.yaml | 69 + tests/lib/tools/suite/tests.pkgs/task.yaml | 41 + .../suite/tests.session-support/task.yaml | 47 + tests/lib/tools/suite/tests.session/task.yaml | 85 + tests/lib/tools/suite/to-one-line/task.yaml | 10 + tests/lib/tools/suite/user-state/task.yaml | 4 + .../lib/tools/suite/version-compare/task.yaml | 43 + tests/lib/tools/tests.backup | 69 + tests/lib/tools/tests.cleanup | 115 + tests/lib/tools/tests.invariant | 180 + tests/lib/tools/tests.pkgs | 143 + tests/lib/tools/tests.pkgs.apt.sh | 55 + tests/lib/tools/tests.pkgs.dnf-yum.sh | 65 + tests/lib/tools/tests.pkgs.pacman.sh | 63 + tests/lib/tools/tests.pkgs.zypper.sh | 57 + tests/lib/tools/tests.session | 391 + tests/lib/tools/to-one-line | 27 + tests/lib/tools/user-state | 60 + tests/lib/tools/version-compare | 308 + tests/lib/uc20-create-partitions/main.go | 106 + tests/main/abort/task.yaml | 38 + tests/main/ack/alice.account | 19 + tests/main/ack/alice.account-key | 31 + tests/main/ack/bob.assertions | 51 + tests/main/ack/task.yaml | 33 + tests/main/alias/task.yaml | 55 + .../bin/apparmor_parser.fake | 41 + tests/main/apparmor-batch-reload/task.yaml | 106 + tests/main/appstream-id/task.yaml | 35 + tests/main/apt-hooks/task.yaml | 56 + tests/main/auth-errors/task.yaml | 27 + tests/main/auto-aliases/task.yaml | 39 + .../auto-refresh-private/expired_macaroons.sh | 13 + .../auto-refresh-private/successful_login.exp | 13 + tests/main/auto-refresh-private/task.yaml | 114 + tests/main/auto-refresh-retry/task.yaml | 71 + tests/main/auto-refresh/task.yaml | 70 + tests/main/bad-interfaces-warn/task.yaml | 18 + .../test-snap/meta/snap.yaml | 9 + tests/main/base-invalid-type/task.yaml | 11 + tests/main/base-migration/task.yaml | 114 + tests/main/base-none/task.yaml | 21 + tests/main/base-policy/task.yaml | 54 + .../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 | 23 + tests/main/base-snaps/task.yaml | 42 + tests/main/boot-state/task.yaml | 84 + tests/main/broken-seeding/task.yaml | 77 + tests/main/buildmode/task.yaml | 22 + .../main/canonical-livepatch-14.04/task.yaml | 41 + tests/main/canonical-livepatch/task.yaml | 19 + tests/main/catalog-update/task.yaml | 30 + tests/main/cgroup-devices/task.sh | 93 + tests/main/cgroup-devices/task.yaml | 9 + .../test-snapd-service/bin/service | 6 + .../test-snapd-service/meta/snap.yaml | 7 + tests/main/cgroup-freezer/task.yaml | 52 + tests/main/cgroup-tracking-failure/task.yaml | 194 + tests/main/cgroup-tracking/task.yaml | 173 + .../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 | 10 + tests/main/chattr/task.yaml | 25 + tests/main/chattr/toggle.go | 51 + .../task.yaml | 37 + tests/main/classic-confinement/task.yaml | 65 + .../main/classic-custom-device-reg/task.yaml | 81 + tests/main/classic-firstboot/task.yaml | 105 + .../classic-prepare-image-no-core/task.yaml | 96 + tests/main/classic-prepare-image/task.yaml | 96 + tests/main/classic-snapd-firstboot/task.yaml | 86 + tests/main/cloud-init/task.yaml | 83 + tests/main/cmdline/task.yaml | 9 + tests/main/cohorts/task.yaml | 33 + 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 | 55 + tests/main/completion/toplevel.exp | 11 + tests/main/completion/try.exp | 6 + tests/main/completion/watch.exp | 7 + tests/main/config-versions/task.yaml | 73 + .../task.yaml | 20 + tests/main/confinement-classic/task.yaml | 48 + tests/main/connect-undo/task.yaml | 47 + .../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 + tests/main/core-snap-not-test-test/task.yaml | 7 + tests/main/core-snap-refresh/task.yaml | 44 + tests/main/core16-base/task.yaml | 9 + tests/main/core16-provided-by-core/task.yaml | 41 + tests/main/core18-configure-hook/task.yaml | 32 + 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 | 40 + .../dbus-activation-name-conflict/task.yaml | 33 + .../dbus-activation-session-legacy/task.yaml | 52 + tests/main/dbus-activation-session/task.yaml | 61 + tests/main/dbus-activation-system/task.yaml | 41 + tests/main/debs/task.yaml | 19 + tests/main/debug-confinement/task.yaml | 12 + tests/main/debug-paths/task.yaml | 13 + tests/main/debug-pprof/task.yaml | 26 + tests/main/debug-sandbox/task.yaml | 29 + tests/main/default-tracks/task.yaml | 39 + tests/main/degraded/task.yaml | 34 + .../main/desktop-portal-filechooser/task.yaml | 78 + tests/main/desktop-portal-open-file/editor.sh | 7 + tests/main/desktop-portal-open-file/task.yaml | 73 + tests/main/desktop-portal-open-uri/task.yaml | 63 + .../desktop-portal-open-uri/web-browser.sh | 5 + .../main/desktop-portal-screenshot/task.yaml | 56 + .../main/dirs-not-shared-with-host/task.yaml | 33 + tests/main/disable-autoconnect/task.yaml | 44 + tests/main/disconnect-undo/task.yaml | 33 + .../meta/hooks/disconnect-plug-network | 6 + .../test-disconnect/meta/snap.yaml | 6 + tests/main/disk-space-awareness/task.yaml | 109 + tests/main/docker-smoke/task.yaml | 16 + .../fake-document-portal.py | 60 + .../main/document-portal-activation/task.yaml | 110 + tests/main/download-timeout/task.yaml | 44 + tests/main/econnreset/task.yaml | 62 + tests/main/enable-disable/task.yaml | 50 + tests/main/exitcodes/task.yaml | 35 + tests/main/experimental-features/task.yaml | 25 + .../fake-netplan-service.py | 61 + .../io.netplan.Netplan.conf | 18 + tests/main/fake-netplan-apply/netplan-info.sh | 3 + tests/main/fake-netplan-apply/snapcraft.yaml | 74 + tests/main/fake-netplan-apply/task.yaml | 155 + tests/main/fakestore-install/task.yaml | 41 + tests/main/fedora-base-smoke/task.yaml | 17 + tests/main/find-private/task.yaml | 49 + tests/main/generic-classic-reg/task.yaml | 28 + tests/main/health/task.yaml | 21 + tests/main/help/task.yaml | 24 + tests/main/high-user-handling/task.yaml | 24 + tests/main/high-user-handling/test.go | 16 + tests/main/hook-permissions/task.yaml | 28 + .../test-snap/meta/hooks/post-refresh | 13 + .../hook-permissions/test-snap/meta/snap.yaml | 5 + tests/main/i18n/task.yaml | 28 + tests/main/install-cache/task.yaml | 7 + tests/main/install-closed-channel/task.yaml | 9 + tests/main/install-errors/task.yaml | 86 + .../install-fontconfig-cache-gen/task.yaml | 53 + tests/main/install-refresh-private/task.yaml | 52 + .../install-refresh-remove-hooks/task.yaml | 94 + tests/main/install-remove-multi/task.yaml | 19 + tests/main/install-sideload-epochs/task.yaml | 27 + tests/main/install-sideload/task.yaml | 83 + .../main/install-socket-activation/task.yaml | 15 + tests/main/install-store-laaaarge/task.yaml | 23 + tests/main/install-store/task.yaml | 47 + .../main/interfaces-account-control/task.yaml | 32 + .../interfaces-accounts-service/task.yaml | 51 + tests/main/interfaces-adb-support/task.yaml | 21 + tests/main/interfaces-alsa/task.yaml | 95 + .../interfaces-appstream-metadata/task.yaml | 58 + .../task.yaml | 157 + .../task.yaml | 66 + tests/main/interfaces-avahi-observe/task.yaml | 48 + .../interfaces-bluetooth-control/task.yaml | 62 + tests/main/interfaces-bluez/task.yaml | 21 + .../task.yaml | 78 + .../test-snapd-sh/bin/sh | 3 + .../test-snapd-sh/meta/snap.yaml | 8 + .../main/interfaces-browser-support/task.yaml | 170 + .../interfaces-calendar-service/task.yaml | 78 + tests/main/interfaces-cli/task.yaml | 27 + .../interfaces-contacts-service/task.yaml | 74 + .../interfaces-content-circular/task.yaml | 18 + .../task.yaml | 23 + .../task.yaml | 34 + tests/main/interfaces-content-mimic/task.yaml | 61 + .../task.yaml | 90 + tests/main/interfaces-content/task.yaml | 60 + tests/main/interfaces-cups-control/task.yaml | 72 + tests/main/interfaces-cups/task.yaml | 66 + .../test-snapd-consumer/bin/sh | 3 + .../test-snapd-consumer/meta/snap.yaml | 12 + .../test-snapd-provider/bin/sh | 3 + .../test-snapd-provider/meta/snap.yaml | 9 + tests/main/interfaces-daemon-notify/task.yaml | 57 + tests/main/interfaces-dbus/task.yaml | 65 + .../task.yaml | 63 + .../interfaces-desktop-host-fonts/task.yaml | 76 + 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 + .../interfaces-firewall-control/task.yaml | 75 + tests/main/interfaces-framebuffer/task.yaml | 49 + tests/main/interfaces-fuse-support/task.yaml | 107 + tests/main/interfaces-fwupd-classic/task.yaml | 34 + tests/main/interfaces-gpg-keys/task.yaml | 71 + .../interfaces-gpg-keys/test-snapd-sh/bin/sh | 3 + .../test-snapd-sh/meta/snap.yaml | 8 + .../main/interfaces-gpg-public-keys/task.yaml | 68 + .../test-snapd-sh/bin/sh | 3 + .../test-snapd-sh/meta/snap.yaml | 8 + .../interfaces-gpio-memory-control/task.yaml | 40 + .../interfaces-hardware-observe/task.yaml | 38 + .../task.yaml | 50 + .../task.yaml | 50 + tests/main/interfaces-home/task.yaml | 119 + .../interfaces-hooks-misbehaving/task.yaml | 11 + tests/main/interfaces-hooks/task.yaml | 100 + .../interfaces-hostname-control/task.yaml | 56 + .../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 | 121 + tests/main/interfaces-kvm/task.yaml | 56 + tests/main/interfaces-libvirt/task.yaml | 69 + .../main/interfaces-locale-control/task.yaml | 96 + .../interfaces-location-control/task.yaml | 62 + tests/main/interfaces-log-observe/task.yaml | 46 + .../interfaces-many-core-provided/task.yaml | 109 + .../interfaces-many-snap-provided/task.yaml | 78 + tests/main/interfaces-mount-observe/task.yaml | 53 + tests/main/interfaces-netlink-audit/task.yaml | 36 + .../interfaces-netlink-connector/task.yaml | 33 + tests/main/interfaces-network-bind/task.yaml | 57 + .../task.yaml | 44 + .../task.yaml | 42 + .../main/interfaces-network-control/task.yaml | 140 + .../main/interfaces-network-manager/task.yaml | 58 + .../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 | 48 + tests/main/interfaces-network/task.yaml | 57 + tests/main/interfaces-opengl-nvidia/task.yaml | 129 + .../interfaces-packagekit-control/task.yaml | 31 + .../task.yaml | 38 + .../main/interfaces-personal-files/task.yaml | 85 + .../test-snapd-sh/bin/sh | 3 + .../test-snapd-sh/meta/snap.yaml | 13 + .../task.yaml | 45 + .../main/interfaces-process-control/task.yaml | 50 + tests/main/interfaces-pulseaudio/task.yaml | 132 + 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 + .../task.yaml | 37 + .../task.yaml | 130 + tests/main/interfaces-snapd-control/task.yaml | 42 + tests/main/interfaces-ssh-keys/task.yaml | 68 + .../interfaces-ssh-keys/test-snapd-sh/bin/sh | 3 + .../test-snapd-sh/meta/snap.yaml | 8 + .../main/interfaces-ssh-public-keys/task.yaml | 60 + .../test-snapd-sh/bin/sh | 3 + .../test-snapd-sh/meta/snap.yaml | 8 + tests/main/interfaces-system-dbus/task.yaml | 50 + 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 | 70 + .../interfaces-system-packages-doc/task.yaml | 22 + .../test-snapd-app/bin/sh | 3 + .../test-snapd-app/meta/snap.yaml | 7 + tests/main/interfaces-time-control/task.yaml | 76 + .../interfaces-timeserver-control/task.yaml | 84 + .../interfaces-timezone-control/task.yaml | 56 + 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 | 63 + .../test-snapd-upower/bin/upowerd.sh | 37 + .../test-snapd-upower/snapcraft.yaml | 88 + tests/main/interfaces-wayland/task.yaml | 42 + .../main/interfaces-x11-unix-socket/task.yaml | 51 + .../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 + .../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 | 24 + tests/main/layout/task.yaml | 98 + tests/main/listing/task.yaml | 91 + 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 | 66 + tests/main/lxd-no-fuse/task.yaml | 56 + .../lxd-postrm-purge/prep-snapd-in-lxd.sh | 41 + tests/main/lxd-postrm-purge/task.yaml | 82 + tests/main/lxd-services-smoke/task.yaml | 34 + tests/main/lxd-snapfuse/task.yaml | 72 + tests/main/lxd-try/task.yaml | 43 + tests/main/lxd/prep-snapd-in-lxd.sh | 41 + tests/main/lxd/task.yaml | 209 + tests/main/manpages/task.yaml | 31 + tests/main/media-sharing/task.yaml | 31 + .../google.ubuntu-16.04-64/HOST.expected.txt | 40 + .../PER-SNAP-16.expected.txt | 77 + .../PER-SNAP-18.expected.txt | 78 + .../PER-SNAP-C7.expected.txt | 40 + .../PER-USER-16.expected.txt | 77 + .../PER-USER-18.expected.txt | 78 + .../PER-USER-C7.expected.txt | 40 + .../google.ubuntu-18.04-64/HOST.expected.txt | 40 + .../PER-SNAP-16.expected.txt | 78 + .../PER-SNAP-18.expected.txt | 79 + .../PER-SNAP-C7.expected.txt | 40 + .../PER-USER-16.expected.txt | 78 + .../PER-USER-18.expected.txt | 79 + .../PER-USER-C7.expected.txt | 40 + .../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 | 214 + .../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 + tests/main/network-retry/task.yaml | 47 + tests/main/nfs-support/task.yaml | 242 + tests/main/nfs-support/test-snapd-sh/bin/sh | 3 + .../nfs-support/test-snapd-sh/meta/snap.yaml | 8 + tests/main/non-home/task.yaml | 31 + tests/main/op-install-failed-undone/task.yaml | 54 + tests/main/op-remove-retry/task.yaml | 44 + tests/main/op-remove/task.yaml | 40 + tests/main/os.query/task.yaml | 132 + tests/main/parallel-install-aliases/task.yaml | 77 + .../parallel-install-auto-aliases/task.yaml | 88 + tests/main/parallel-install-basic/task.yaml | 82 + tests/main/parallel-install-classic/task.yaml | 83 + .../task.yaml | 37 + .../parallel-install-common-dirs/task.yaml | 82 + tests/main/parallel-install-desktop/task.yaml | 42 + .../task.yaml | 74 + .../parallel-install-interfaces/task.yaml | 68 + tests/main/parallel-install-layout/task.yaml | 83 + tests/main/parallel-install-local/task.yaml | 45 + .../parallel-install-remove-after/task.yaml | 71 + .../main/parallel-install-services/task.yaml | 60 + .../parallel-install-snap-icons/task.yaml | 34 + tests/main/parallel-install-store/task.yaml | 31 + tests/main/postrm-purge/task.yaml | 115 + tests/main/prefer/task.yaml | 36 + .../main/prepare-image-grub-core18/task.yaml | 50 + tests/main/prepare-image-grub/task.yaml | 88 + tests/main/prepare-image-uboot-uc20/task.yaml | 76 + tests/main/prepare-image-uboot/task.yaml | 77 + tests/main/preseed-lxd/metadata.yaml | 7 + tests/main/preseed-lxd/preseed-prepare.sh | 6 + tests/main/preseed-lxd/task.yaml | 154 + tests/main/preseed-reset/task.yaml | 70 + tests/main/preseed/task.yaml | 138 + tests/main/proxy-no-core/task.yaml | 53 + tests/main/proxy/task.yaml | 33 + tests/main/refresh-all-undo/task.yaml | 84 + tests/main/refresh-all/task.yaml | 70 + tests/main/refresh-amend/task.yaml | 25 + tests/main/refresh-app-awareness/task.yaml | 100 + .../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 | 38 + tests/main/refresh-delta-from-core/task.yaml | 27 + tests/main/refresh-delta/task.yaml | 23 + tests/main/refresh-devmode/task.yaml | 82 + tests/main/refresh-hold/task.yaml | 20 + tests/main/refresh-undo/task.yaml | 48 + tests/main/refresh-with-epoch-bump/task.yaml | 32 + tests/main/refresh/task.yaml | 174 + .../regression-home-snap-root-owned/task.yaml | 37 + .../simplesnap.v1/meta/snap.yaml | 5 + .../simplesnap.v2/meta/snap.yaml | 4 + tests/main/remove-auto-connections/task.yaml | 50 + tests/main/remove-errors/task.yaml | 23 + tests/main/retry-network/detect-retry.go | 22 + tests/main/retry-network/task.yaml | 59 + tests/main/retryable-error/task.yaml | 31 + tests/main/revert-devmode/task.yaml | 93 + tests/main/revert-sideload/task.yaml | 17 + tests/main/revert/task.yaml | 107 + tests/main/sanitycheck/task.yaml | 33 + tests/main/searching/task.yaml | 77 + tests/main/seccomp-statx/task.yaml | 17 + tests/main/security-apparmor/task.yaml | 20 + .../security-dev-input-event-denied/task.yaml | 103 + .../security-device-cgroups-classic/task.yaml | 37 + .../security-device-cgroups-devmode/task.yaml | 50 + .../task.yaml | 53 + .../task.yaml | 55 + .../security-device-cgroups-strict/task.yaml | 43 + tests/main/security-device-cgroups/task.yaml | 126 + tests/main/security-devpts/task.yaml | 29 + tests/main/security-private-tmp/task.yaml | 49 + .../main/security-private-tmp/tmp-create.exp | 15 + tests/main/security-profiles/task.yaml | 28 + tests/main/security-seccomp/task.yaml | 85 + tests/main/security-setuid-root/task.yaml | 42 + .../security-udev-input-subsystem/task.yaml | 82 + .../selinux-classic-confinement/task.yaml | 48 + tests/main/selinux-clean/task.yaml | 113 + tests/main/selinux-data-context/task.yaml | 75 + tests/main/selinux-lxd/task.yaml | 81 + tests/main/selinux-snap-restorecon/task.yaml | 55 + 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 + .../services-disable-refresh-hook/task.yaml | 21 + .../services-disabled-kept-happy/task.yaml | 367 + .../services-disabled-kept-unhappy/task.yaml | 269 + .../task.yaml | 11 + .../bin/svc.sh | 5 + .../meta/hooks/install | 11 + .../meta/snap.yaml | 9 + .../services-multi-service-failing/task.yaml | 9 + tests/main/services-refresh-mode/task.yaml | 31 + tests/main/services-snapctl/task.yaml | 72 + tests/main/services-start-timeout/task.yaml | 21 + .../main/services-stop-mode-sigkill/task.yaml | 44 + tests/main/services-stop-mode/task.yaml | 64 + tests/main/services-stop-timeout/task.yaml | 33 + tests/main/services-timer/task.yaml | 50 + tests/main/services-watchdog/task.yaml | 57 + tests/main/set-proxy-store/task.yaml | 79 + tests/main/snap-advise-command/task.yaml | 66 + tests/main/snap-cli-no-managers/task.yaml | 22 + .../has-sys-admin.c | 52 + .../snap-confine-drops-sys-admin/task.yaml | 56 + tests/main/snap-confine-from-core/task.yaml | 67 + tests/main/snap-confine-privs/task.yaml | 70 + tests/main/snap-confine-privs/uids-and-gids.c | 40 + .../task.yaml | 56 + .../test-snapd-app/bin/sh | 3 + .../test-snapd-app/meta/snap.yaml | 9 + tests/main/snap-confine/task.yaml | 52 + tests/main/snap-connect/task.yaml | 63 + tests/main/snap-connections/task.yaml | 95 + .../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 | 12 + tests/main/snap-debug-state/task.yaml | 38 + tests/main/snap-debug-timings/task.yaml | 8 + tests/main/snap-device-helper-nvme/task.yaml | 43 + 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 | 95 + .../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 | 79 + 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/task.yaml | 35 + tests/main/snap-mgmt/task.yaml | 130 + tests/main/snap-model/task.yaml | 41 + tests/main/snap-network-errors/task.yaml | 39 + tests/main/snap-pack/task.yaml | 38 + tests/main/snap-readme/task.yaml | 13 + tests/main/snap-remove-not-mounted/task.yaml | 16 + tests/main/snap-repair/task.yaml | 19 + 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 | 35 + tests/main/snap-run-alias/task.yaml | 35 + tests/main/snap-run-gdbserver/task.yaml | 34 + tests/main/snap-run-hook/task.yaml | 48 + tests/main/snap-run-symlink-error/task.yaml | 23 + tests/main/snap-run-symlink/task.yaml | 27 + .../main/snap-run-userdata-current/task.yaml | 40 + tests/main/snap-run/task.yaml | 81 + tests/main/snap-seccomp-syscalls/listcalls.go | 13 + tests/main/snap-seccomp-syscalls/task.yaml | 43 + tests/main/snap-seccomp/task.yaml | 141 + .../svc.v1/meta/hooks/install | 5 + .../svc.v1/meta/snap.yaml | 14 + .../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 | 47 + tests/main/snap-service/task.yaml | 27 + tests/main/snap-services/task.yaml | 19 + .../task.yaml | 71 + .../task.yaml | 63 + .../task.yaml | 49 + tests/main/snap-set-core-w-no-core/task.yaml | 37 + 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 | 11 + tests/main/snap-system-env/task.yaml | 35 + tests/main/snap-system-key/task.yaml | 83 + tests/main/snap-unset/task.yaml | 26 + tests/main/snap-update-ns/task.yaml | 80 + .../task.yaml | 57 + .../task.yaml | 42 + .../task.yaml | 57 + .../task.yaml | 64 + tests/main/snap-user-service/task.yaml | 34 + .../task.yaml | 31 + tests/main/snap-userd-reexec/task.yaml | 25 + tests/main/snap-validate-basic/task.yaml | 51 + tests/main/snap-wait/task.yaml | 25 + tests/main/snapctl-from-snap/task.yaml | 98 + tests/main/snapctl-is-connected/task.yaml | 45 + .../test-snap/bin/checkconn | 3 + .../test-snap/meta/snap.yaml | 8 + tests/main/snapctl/task.yaml | 50 + tests/main/snapd-certs/task.yaml | 45 + .../main/snapd-go-socket-activated/task.yaml | 39 + tests/main/snapd-notify/task.yaml | 34 + tests/main/snapd-reexec-snapd-snap/task.yaml | 35 + tests/main/snapd-reexec/task.yaml | 102 + tests/main/snapd-slow-startup/task.yaml | 41 + tests/main/snapd-snap-auto-install/task.yaml | 26 + tests/main/snapd-snap-removal/task.yaml | 37 + tests/main/snapd-snap-transition/task.yaml | 23 + tests/main/snapd-snap/task.yaml | 42 + tests/main/snapd-without-core/task.yaml | 42 + tests/main/snaps-state/task.yaml | 76 + tests/main/snapshot-basic/task.yaml | 153 + tests/main/snapshot-cross-revno/task.yaml | 59 + tests/main/snapshot-users/task.yaml | 121 + .../task.yaml | 43 + tests/main/stale-base-snap/task.yaml | 90 + tests/main/static/task.yaml | 15 + tests/main/sudo-env/task.yaml | 51 + tests/main/system-core-alias/task.yaml | 20 + tests/main/system-usernames-illegal/task.yaml | 17 + .../system-usernames-install-twice/task.yaml | 28 + .../system-usernames-missing-user/task.yaml | 28 + tests/main/system-usernames/task.yaml | 798 ++ tests/main/systemd-service/task.yaml | 18 + tests/main/try-non-fatal/task.yaml | 19 + tests/main/try-snap-goes-away/task.yaml | 51 + tests/main/try-snap-is-optional/task.yaml | 12 + tests/main/try-twice-with-daemon/task.yaml | 36 + tests/main/try-with-hooks/task.yaml | 32 + tests/main/try/task.yaml | 82 + .../uc20-create-partitions-encrypt/task.yaml | 162 + .../task.yaml | 102 + tests/main/uc20-create-partitions/task.yaml | 147 + tests/main/umask/task.yaml | 22 + tests/main/unhandled-task/task.yaml | 30 + tests/main/upgrade-from-2.15/task.yaml | 86 + 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 | 60 + .../validate-container-failures/task.yaml | 29 + tests/main/vitality/task.yaml | 34 + tests/main/whoami/task.yaml | 19 + tests/main/writable-areas/task.yaml | 34 + 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 | 98 + tests/main/xdg-open-portal/web-browser.sh | 5 + tests/main/xdg-open/task.yaml | 78 + tests/main/xdg-settings/task.yaml | 105 + tests/manual-tests.md | 237 + tests/nested/classic/hotplug/task.yaml | 195 + .../task.yaml | 56 + tests/nested/core/core-revert/task.yaml | 64 + .../core/core-snap-refresh-on-core/task.yaml | 61 + .../core/extra-snaps-assertions/task.yaml | 21 + tests/nested/core/hotplug/task.yaml | 184 + tests/nested/core/image-build/task.yaml | 18 + tests/nested/core20/basic/task.yaml | 50 + tests/nested/core20/degraded/task.yaml | 49 + .../core20/gadget-reseal/manip_gadget.py | 50 + tests/nested/core20/gadget-reseal/task.yaml | 56 + tests/nested/core20/kernel-failover/task.yaml | 92 + tests/nested/core20/kernel-reseal/task.yaml | 55 + tests/nested/core20/tpm/task.yaml | 72 + .../cloud-init-never-used-not-vuln/task.yaml | 138 + .../cloud-init-nocloud-not-vuln/task.yaml | 145 + .../manual/core-early-config/defaults.yaml | 9 + tests/nested/manual/core-early-config/install | 10 + .../nested/manual/core-early-config/task.yaml | 65 + .../manual/core20-early-config/defaults.yaml | 13 + .../nested/manual/core20-early-config/install | 13 + .../manual/core20-early-config/task.yaml | 90 + tests/nested/manual/core20-save/task.yaml | 101 + .../devmode-snap-seeded-dangerous/task.yaml | 31 + .../uc20-devmode/meta/snap.yaml | 9 + .../uc20-devmode/true | 3 + .../defaults.yaml | 6 + .../prepare-device | 3 + .../task.yaml | 133 + .../defaults.yaml | 6 + .../prepare-device | 3 + .../task.yaml | 170 + tests/nested/manual/minimal-smoke/task.yaml | 36 + tests/nested/manual/preseed/task.yaml | 150 + .../refresh-revert-fundamentals/task.yaml | 138 + .../manual/snapd-refresh-from-old/task.yaml | 71 + tests/nested/manual/uc20-fde-hooks/task.yaml | 42 + .../manual/uc20-storage-safety/task.yaml | 71 + .../task.yaml | 87 + .../task.yaml | 78 + .../classic-ubuntu-core-transition/task.yaml | 128 + tests/nightly/install-snaps/task.yaml | 136 + .../nightly/interfaces-openvswitch/task.yaml | 112 + tests/nightly/sbuild/task.yaml | 51 + 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 | 31 + tests/regression/lp-1641885/task.yaml | 30 + tests/regression/lp-1644439/task.yaml | 48 + tests/regression/lp-1665004/task.yaml | 17 + tests/regression/lp-1667385/task.yaml | 23 + tests/regression/lp-1693042/task.yaml | 18 + tests/regression/lp-1704860/snap-env-query.sh | 1 + tests/regression/lp-1704860/task.yaml | 27 + tests/regression/lp-1732555/task.yaml | 17 + tests/regression/lp-1764977/task.yaml | 29 + tests/regression/lp-1797556/task.yaml | 26 + .../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 | 80 + tests/regression/lp-1803535/task.yaml | 6 + tests/regression/lp-1803542/task.yaml | 49 + tests/regression/lp-1805485/task.yaml | 15 + tests/regression/lp-1805838/task.yaml | 52 + tests/regression/lp-1808821/task.sh | 8 + tests/regression/lp-1808821/task.yaml | 16 + .../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 | 22 + tests/regression/lp-1812973/lp-1812973.c | 63 + tests/regression/lp-1812973/task.yaml | 18 + .../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 | 14 + tests/regression/lp-1813963/task.yaml | 106 + tests/regression/lp-1815722/task.yaml | 8 + tests/regression/lp-1815869/hello.py | 3 + tests/regression/lp-1815869/task.yaml | 68 + tests/regression/lp-1819728/task.yaml | 31 + tests/regression/lp-1825883/task.yaml | 33 + .../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 | 15 + .../lp-1844496/test-snapd-layout/bin/sh | 3 + .../test-snapd-layout/meta/snap.yaml | 11 + .../x86_64-linux-gnu/wpe-webkit-1.0/canary | 1 + .../usr/wpe-webkit-1.0/.gitkeep | 0 tests/regression/lp-1848567/task.yaml | 43 + .../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 | 47 + .../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 | 25 + .../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 | 58 + .../lp-1862637/test-snapd-app/bin/sh | 2 + .../lp-1862637/test-snapd-app/meta/snap.yaml | 8 + tests/regression/lp-1866095/task.yaml | 14 + .../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 | 26 + tests/regression/lp-1871652/systemctl | 6 + tests/regression/lp-1871652/task.yaml | 63 + tests/regression/lp-1884849/task.yaml | 35 + 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 | 19 + .../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 | 43 + .../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 | 18 + tests/regression/lp-1906821/task.yaml | 37 + .../container-mgr-snap/bin/simple.sh | 3 + .../container-mgr-snap/meta/snap.yaml | 91 + tests/regression/lp-1910456/task.yaml | 143 + tests/regression/rhbz-1584461/task.yaml | 42 + tests/regression/rhbz-1708991/task.yaml | 33 + tests/smoke/find-info/task.yaml | 9 + tests/smoke/install/task.yaml | 68 + tests/smoke/remove/task.yaml | 18 + tests/smoke/sandbox/task.yaml | 82 + tests/smoke/sandbox/test-snapd-sandbox/bin/sh | 3 + .../sandbox/test-snapd-sandbox/meta/snap.yaml | 10 + tests/snapd-state.md | 11 + tests/unit/c-unit-tests-clang/task.yaml | 33 + tests/unit/c-unit-tests-gcc/task.yaml | 33 + tests/unit/go/task.yaml | 47 + .../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 | 21 + tests/upgrade/basic/task.yaml | 174 + tests/upgrade/selinux-relabel/task.yaml | 33 + tests/upgrade/snapd-xdg-open/task.yaml | 44 + tests/util/benchmark.sh | 21 + testutil/base.go | 55 + testutil/containschecker.go | 152 + testutil/containschecker_test.go | 223 + testutil/dbustest.go | 136 + testutil/dbustest_test.go | 51 + testutil/exec.go | 232 + testutil/exec_test.go | 139 + testutil/export_test.go | 44 + testutil/filecontentchecker.go | 115 + testutil/filecontentchecker_test.go | 102 + testutil/filepresencechecker.go | 59 + testutil/filepresencechecker_test.go | 54 + testutil/intcheckers.go | 98 + testutil/intcheckers_test.go | 55 + testutil/lowlevel.go | 555 + testutil/lowlevel_test.go | 797 ++ 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 | 55 + testutil/timeouts.go | 43 + testutil/timeouts_test.go | 45 + timeout/timeout.go | 76 + timeout/timeout_test.go | 65 + timeutil/export_test.go | 35 + timeutil/human.go | 80 + timeutil/human_test.go | 93 + timeutil/schedule.go | 903 ++ timeutil/schedule_test.go | 1266 ++ timings/export_test.go | 40 + timings/helpers.go | 28 + timings/state.go | 193 + timings/timings.go | 121 + timings/timings_test.go | 388 + update-pot | 91 + usersession/agent/export_test.go | 52 + usersession/agent/response.go | 168 + usersession/agent/rest_api.go | 317 + usersession/agent/rest_api_test.go | 638 + usersession/agent/session_agent.go | 328 + usersession/agent/session_agent_test.go | 216 + usersession/autostart/autostart.go | 278 + usersession/autostart/autostart_test.go | 326 + usersession/autostart/export_test.go | 45 + usersession/client/client.go | 289 + usersession/client/client_test.go | 459 + usersession/userd/export_test.go | 32 + usersession/userd/helpers.go | 68 + usersession/userd/launcher.go | 248 + usersession/userd/launcher_test.go | 199 + usersession/userd/settings.go | 354 + usersession/userd/settings_test.go | 394 + 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 | 100 + usersession/userd/userd.go | 132 + usersession/xdgopenproxy/export_test.go | 60 + usersession/xdgopenproxy/portal_launcher.go | 174 + .../xdgopenproxy/portal_launcher_test.go | 263 + usersession/xdgopenproxy/userd_launcher.go | 51 + .../xdgopenproxy/userd_launcher_test.go | 173 + usersession/xdgopenproxy/xdgopenproxy.go | 88 + usersession/xdgopenproxy/xdgopenproxy_test.go | 104 + vendor/vendor.json | 314 + wrappers/binaries.go | 92 + wrappers/binaries_test.go | 165 + wrappers/core18.go | 603 + wrappers/core18_test.go | 406 + wrappers/dbus.go | 184 + wrappers/dbus_test.go | 216 + wrappers/desktop.go | 312 + wrappers/desktop_test.go | 594 + wrappers/export_test.go | 65 + wrappers/icons.go | 121 + wrappers/icons_test.go | 137 + wrappers/services.go | 1313 ++ wrappers/services_gen_test.go | 941 ++ wrappers/services_test.go | 1968 +++ x11/xauth.go | 151 + x11/xauth_test.go | 74 + 3886 files changed, 742261 insertions(+) create mode 100644 .clang-format create mode 100644 .github/spread-problem-matcher.json create mode 100644 .github/workflows/cla-check.yaml create mode 100644 .github/workflows/macos-sanity.yaml create mode 100644 .github/workflows/test.yaml create mode 100644 .gitignore create mode 100644 .mailmap create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 COPYING create mode 100644 HACKING.md create mode 100644 PULL_REQUEST_TEMPLATE.md create mode 100644 README.md create mode 100644 advisor/backend.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 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/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/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/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/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/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/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/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/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/seal.go create mode 100644 boot/seal_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/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/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 build-aux/snap/snapcraft.yaml 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/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/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/packages.go create mode 100644 client/packages_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/.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/cgroup-freezer-support.c create mode 100644 cmd/libsnap-confine-private/cgroup-freezer-support.h 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/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.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 100755 cmd/snap-confine/snap-device-helper create mode 100644 cmd/snap-confine/snap-device-helper-test.c 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-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-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 100644 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/main_test.go create mode 100644 cmd/snap-preseed/preseed_linux.go create mode 100644 cmd/snap-preseed/preseed_other.go create mode 100644 cmd/snap-preseed/reset.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/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_ppc64le.go create mode 100644 cmd/snap-seccomp/main_test.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/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-update-ns/xdg.go create mode 100644 cmd/snap-update-ns/xdg_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_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_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_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_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/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 100755 cmd/snapd-apparmor/snapd-apparmor 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/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 daemon/api.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_asserts.go create mode 100644 daemon/api_asserts_test.go create mode 100644 daemon/api_base_d_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_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_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_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_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_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/export_api_aliases_test.go create mode 100644 daemon/export_api_apps_test.go create mode 100644 daemon/export_api_asserts_test.go create mode 100644 daemon/export_api_cohort_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_sideload_n_try_test.go create mode 100644 daemon/export_api_snap_file_test.go create mode 100644 daemon/export_api_snapctl_test.go create mode 100644 daemon/export_api_snapshots_test.go create mode 100644 daemon/export_api_systems_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/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/env/Makefile 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/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-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.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 120000 debian 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/hints.go create mode 100644 desktop/notification/hints_test.go create mode 100644 desktop/notification/notify.go create mode 100644 desktop/notification/notify_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 errtracker/errtracker.go create mode 100644 errtracker/errtracker_test.go create mode 100644 errtracker/export_test.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_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/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/internal/mkfs.go create mode 100644 gadget/internal/mkfs_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/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 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 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/backends/export_test.go 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/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/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/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_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/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/dummy.go create mode 100644 interfaces/builtin/dvb.go create mode 100644 interfaces/builtin/dvb_test.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/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_module_control.go create mode 100644 interfaces/builtin/kernel_module_control_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_hub.go create mode 100644 interfaces/builtin/media_hub_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_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/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/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/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/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/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/removable_media.go create mode 100644 interfaces/builtin/removable_media_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/serial_port.go create mode 100644 interfaces/builtin/serial_port_test.go create mode 100644 interfaces/builtin/shutdown.go create mode 100644 interfaces/builtin/shutdown_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/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/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/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/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/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/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/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/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/kernel.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/normalize.go create mode 100644 metautil/normalize_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/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/labels.go create mode 100644 osutil/disks/labels_test.go create mode 100644 osutil/disks/mockdisk.go create mode 100644 osutil/disks/mockdisk_test.go create mode 100644 osutil/doc.go create mode 100644 osutil/env.go create mode 100644 osutil/env_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_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/io.go create mode 100644 osutil/io_test.go create mode 100644 osutil/kcmdline.go create mode 100644 osutil/kcmdline_test.go create mode 100644 osutil/mkdirallchown.go create mode 100644 osutil/mkdirallchown_test.go create mode 100644 osutil/mockable.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_linux.go create mode 100644 osutil/mountinfo_linux_test.go create mode 100644 osutil/mountprofile_linux.go create mode 100644 osutil/mountprofile_linux_test.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_darwin.go create mode 100644 osutil/overlay_linux.go create mode 100644 osutil/overlay_linux_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/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/go.mod 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/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/experimental.go create mode 100644 overlord/configstate/configcore/experimental_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/journal.go create mode 100644 overlord/configstate/configcore/journal_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/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/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/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/helpers.go create mode 100644 overlord/configstate/helpers_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/configstate/settings/settings.go create mode 100644 overlord/configstate/settings/settings_test.go create mode 100644 overlord/devicestate/crypto.go create mode 100644 overlord/devicestate/devicectx.go create mode 100644 overlord/devicestate/devicemgr.go create mode 100644 overlord/devicestate/devicestate.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_mode_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/fde/fde.go create mode 100644 overlord/devicestate/fde/fde_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_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_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/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/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/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/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/udevmonitor/udevmon.go create mode 100644 overlord/ifacestate/udevmonitor/udevmon_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/servicestate/export_test.go create mode 100644 overlord/servicestate/helpers.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/servicestate.go create mode 100644 overlord/servicestate/servicestate_test.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/aliasesv2.go create mode 100644 overlord/snapstate/aliasesv2_test.go create mode 100644 overlord/snapstate/autorefresh.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/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/fontconfig.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/conflict.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_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_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/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/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/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/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 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 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/0001-cmd-snap-seccomp-use-upstream-seccomp-package.patch create mode 100644 packaging/debian-sid/patches/0002-cmd-snap-seccomp-skip-tests-that-fail-on-4.19.patch 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/0005-advisor-errtracker-use-upstream-bolt-package.patch create mode 100644 packaging/debian-sid/patches/0006-systemd-disable-snapfuse-system.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-29 create mode 120000 packaging/fedora-30 create mode 120000 packaging/fedora-31 create mode 120000 packaging/fedora-32 create mode 120000 packaging/fedora-33 create mode 120000 packaging/fedora-rawhide create mode 100644 packaging/fedora/snapd.spec create mode 120000 packaging/opensuse-15.0 create mode 120000 packaging/opensuse-15.1 create mode 120000 packaging/opensuse-15.2 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 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 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/rand.go create mode 100644 randutil/rand_test.go create mode 100755 release-tools/debian-package-builder create mode 100755 release-tools/repack-debian-tarball.sh 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/process.go create mode 100644 sandbox/apparmor/process_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/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 sanity/apparmor_lxd.go create mode 100644 sanity/apparmor_lxd_test.go create mode 100644 sanity/cgroup.go create mode 100644 sanity/cgroup_test.go create mode 100644 sanity/check.go create mode 100644 sanity/check_test.go create mode 100644 sanity/export_test.go create mode 100644 sanity/squashfs.go create mode 100644 sanity/squashfs_test.go create mode 100644 sanity/version.go create mode 100644 sanity/version_test.go create mode 100644 sanity/wsl.go create mode 100644 sanity/wsl_test.go create mode 100644 secboot/encrypt.go create mode 100644 secboot/encrypt_dummy.go create mode 100644 secboot/encrypt_test.go create mode 100644 secboot/encrypt_tpm.go create mode 100644 secboot/encrypt_tpm_test.go create mode 100644 secboot/export_test.go create mode 100644 secboot/secboot.go create mode 100644 secboot/secboot_dummy.go create mode 100644 secboot/secboot_tpm.go create mode 100644 secboot/secboot_tpm_test.go 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/internal/validate.go create mode 100644 seed/internal/validate_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/helpers.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/container.go create mode 100644 snap/container_test.go create mode 100644 snap/epoch.go create mode 100644 snap/epoch_test.go create mode 100644 snap/errors.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/internal/file.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/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/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/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 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 100755 spread-shellcheck create mode 100644 spread.yaml create mode 100644 store/auth.go create mode 100644 store/auth_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/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/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 sysconfig/cloudinit.go create mode 100644 sysconfig/cloudinit_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 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/any-python create mode 120000 tests/bin/mountinfo.query create mode 120000 tests/bin/not create mode 120000 tests/bin/os.query create mode 120000 tests/bin/retry create mode 120000 tests/bin/snapd.tool create mode 120000 tests/bin/tests.backup create mode 120000 tests/bin/tests.cleanup create mode 120000 tests/bin/tests.invariant create mode 120000 tests/bin/tests.pkgs create mode 120000 tests/bin/tests.session 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/.just a hidden file create mode 100644 tests/completion/data/twisted/this is a file with spaces in it.doc create mode 100644 tests/completion/data/twisted/this isn't.innit 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/basic18/task.yaml create mode 100644 tests/core/basic20/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 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/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-vitality/gadget-vitality-hint.yaml create mode 100644 tests/core/gadget-config-defaults-vitality/task.yaml create mode 100644 tests/core/gadget-config-defaults/gadget-rsyslog.yaml create mode 100644 tests/core/gadget-config-defaults/gadget-ssh-common.yaml create mode 100644 tests/core/gadget-config-defaults/gadget-ssh-oneline.yaml create mode 100644 tests/core/gadget-config-defaults/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 100644 tests/core/iio/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/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/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 100644 tests/core/snap-set-core-config/task.yaml create mode 100644 tests/core/snapd-failover/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/uboot-unpacked-assets/task.yaml create mode 100755 tests/core/uc20-recovery/mock-shutdown create mode 100644 tests/core/uc20-recovery/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/cross/go-build/task.yaml create mode 100644 tests/external-backend.md create mode 100644 tests/go.mod 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/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-auto-import.assert create mode 100644 tests/lib/assertions/developer1-auto-import.json 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-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/nested-18-amd64.model create mode 100644 tests/lib/assertions/nested-18-amd64.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-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/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 100755 tests/lib/best_golang.py create mode 100644 tests/lib/cache/README.txt create mode 100755 tests/lib/changes.sh create mode 100755 tests/lib/cla_check.py 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 100644 tests/lib/dirs.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/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_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/fde-setup.go create mode 100644 tests/lib/gendeveloper1model/main.go create mode 100644 tests/lib/hotplug.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 100644 tests/lib/names.sh create mode 100644 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/quiet.sh create mode 100644 tests/lib/ramdisk.sh create mode 100644 tests/lib/random.sh create mode 100755 tests/lib/reset.sh create mode 100644 tests/lib/snaps.sh create mode 100755 tests/lib/snaps/account-control-consumer-core18/bin/chpasswd create mode 100755 tests/lib/snaps/account-control-consumer-core18/bin/deluser create mode 100755 tests/lib/snaps/account-control-consumer-core18/bin/useradd create mode 100644 tests/lib/snaps/account-control-consumer-core18/meta/snap.yaml create mode 100755 tests/lib/snaps/account-control-consumer/bin/chpasswd create mode 100755 tests/lib/snaps/account-control-consumer/bin/deluser create mode 100755 tests/lib/snaps/account-control-consumer/bin/useradd create mode 100644 tests/lib/snaps/account-control-consumer/meta/snap.yaml 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 100755 tests/lib/snaps/basic-iface-hooks-consumer/meta/hooks/configure create mode 100755 tests/lib/snaps/basic-iface-hooks-consumer/meta/hooks/connect-plug-consumer create mode 100755 tests/lib/snaps/basic-iface-hooks-consumer/meta/hooks/disconnect-plug-consumer create mode 100755 tests/lib/snaps/basic-iface-hooks-consumer/meta/hooks/prepare-plug-consumer create mode 100755 tests/lib/snaps/basic-iface-hooks-consumer/meta/hooks/unprepare-plug-consumer create mode 100644 tests/lib/snaps/basic-iface-hooks-consumer/meta/snap.yaml create mode 100755 tests/lib/snaps/basic-iface-hooks-producer/meta/hooks/configure create mode 100755 tests/lib/snaps/basic-iface-hooks-producer/meta/hooks/connect-slot-producer create mode 100755 tests/lib/snaps/basic-iface-hooks-producer/meta/hooks/disconnect-slot-producer create mode 100755 tests/lib/snaps/basic-iface-hooks-producer/meta/hooks/prepare-slot-producer create mode 100755 tests/lib/snaps/basic-iface-hooks-producer/meta/hooks/unprepare-slot-producer create mode 100644 tests/lib/snaps/basic-iface-hooks-producer/meta/snap.yaml create mode 100755 tests/lib/snaps/basic-run/bin/echo create mode 100644 tests/lib/snaps/basic-run/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 100755 tests/lib/snaps/browser-support-consumer/bin/cmd create mode 100644 tests/lib/snaps/browser-support-consumer/meta/snap.yaml.in create mode 100644 tests/lib/snaps/classic-gadget-18/meta/gadget.yaml create mode 100755 tests/lib/snaps/classic-gadget-18/meta/hooks/prepare-device create mode 100644 tests/lib/snaps/classic-gadget-18/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/command-chain/chain1 create mode 100755 tests/lib/snaps/command-chain/chain2 create mode 100755 tests/lib/snaps/command-chain/chain3 create mode 100755 tests/lib/snaps/command-chain/chain4 create mode 100755 tests/lib/snaps/command-chain/hello create mode 100755 tests/lib/snaps/command-chain/meta/hooks/configure create mode 100644 tests/lib/snaps/command-chain/meta/snap.yaml create mode 100755 tests/lib/snaps/command-chain/run 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/data-writer/bin/write-data create mode 100644 tests/lib/snaps/data-writer/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/failing-config-hooks/meta/hooks/configure create mode 100644 tests/lib/snaps/failing-config-hooks/meta/snap.yaml create mode 100755 tests/lib/snaps/firewall-control-consumer/bin/consumer create mode 100644 tests/lib/snaps/firewall-control-consumer/meta/snap.yaml 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/hardware-observe-consumer/bin/consumer create mode 100644 tests/lib/snaps/hardware-observe-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/iio-consumer/bin/read create mode 100755 tests/lib/snaps/iio-consumer/bin/write create mode 100644 tests/lib/snaps/iio-consumer/meta/snap.yaml create mode 100755 tests/lib/snaps/locale-control-consumer/bin/get create mode 100755 tests/lib/snaps/locale-control-consumer/bin/set create mode 100644 tests/lib/snaps/locale-control-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/modem-manager-consumer/bin/consumer create mode 100644 tests/lib/snaps/modem-manager-consumer/meta/snap.yaml create mode 100755 tests/lib/snaps/mount-observe-consumer/bin/consumer create mode 100644 tests/lib/snaps/mount-observe-consumer/meta/snap.yaml create mode 100755 tests/lib/snaps/network-bind-consumer/bin/consumer create mode 100644 tests/lib/snaps/network-bind-consumer/meta/snap.yaml 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/network-control-consumer/bin/cmd create mode 100644 tests/lib/snaps/network-control-consumer/meta/snap.yaml create mode 100755 tests/lib/snaps/network-observe-consumer/bin/consumer create mode 100644 tests/lib/snaps/network-observe-consumer/meta/snap.yaml create mode 100755 tests/lib/snaps/process-control-consumer/bin/signal create mode 100644 tests/lib/snaps/process-control-consumer/meta/snap.yaml create mode 100755 tests/lib/snaps/serial-port-hotplug/bin/consumer create mode 100644 tests/lib/snaps/serial-port-hotplug/meta/snap.yaml create mode 100755 tests/lib/snaps/shutdown-introspection-consumer/bin/consumer create mode 100644 tests/lib/snaps/shutdown-introspection-consumer/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-from-snap-core18/bin/snapctl-get create mode 100755 tests/lib/snaps/snapctl-from-snap-core18/bin/snapctl-set create mode 100755 tests/lib/snaps/snapctl-from-snap-core18/meta/hooks/configure create mode 100644 tests/lib/snaps/snapctl-from-snap-core18/meta/snap.yaml create mode 100755 tests/lib/snaps/snapctl-from-snap/bin/snapctl-get create mode 100755 tests/lib/snaps/snapctl-from-snap/bin/snapctl-set create mode 100755 tests/lib/snaps/snapctl-from-snap/meta/hooks/configure create mode 100644 tests/lib/snaps/snapctl-from-snap/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 100755 tests/lib/snaps/test-classic-cgroup/bin/read-fb create mode 100755 tests/lib/snaps/test-classic-cgroup/bin/read-kmsg create mode 100644 tests/lib/snaps/test-classic-cgroup/meta/snap.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 100644 tests/lib/snaps/test-snapd-accounts-service/list-accounts.c create mode 100644 tests/lib/snaps/test-snapd-accounts-service/snapcraft.yaml create mode 100755 tests/lib/snaps/test-snapd-adb-support/bin/sh create mode 100644 tests/lib/snaps/test-snapd-adb-support/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-appstreamid/bin/run create mode 100644 tests/lib/snaps/test-snapd-appstreamid/meta/snap.yaml create mode 100644 tests/lib/snaps/test-snapd-audio-record/Makefile create mode 100755 tests/lib/snaps/test-snapd-audio-record/files/bin/pawrap create mode 100644 tests/lib/snaps/test-snapd-audio-record/snapcraft.yaml create mode 100644 tests/lib/snaps/test-snapd-audio-record/src/Makefile create mode 100644 tests/lib/snaps/test-snapd-audio-record/src/parec-simple.c 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 100755 tests/lib/snaps/test-snapd-autopilot-consumer/consumer create mode 100644 tests/lib/snaps/test-snapd-autopilot-consumer/provider.py create mode 100644 tests/lib/snaps/test-snapd-autopilot-consumer/snapcraft.yaml create mode 100755 tests/lib/snaps/test-snapd-autopilot-consumer/wrapper create mode 100644 tests/lib/snaps/test-snapd-base-bare/Makefile create mode 100644 tests/lib/snaps/test-snapd-base-bare/snapcraft.yaml create mode 100755 tests/lib/snaps/test-snapd-base-none-invalid/bin/cmd create mode 100644 tests/lib/snaps/test-snapd-base-none-invalid/meta/snap.yaml create mode 100644 tests/lib/snaps/test-snapd-base-none/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 100644 tests/lib/snaps/test-snapd-busybox-static/snapcraft.yaml 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-classic-service-hooks/bin/service create mode 100755 tests/lib/snaps/test-snapd-classic-service-hooks/meta/hooks/configure create mode 100755 tests/lib/snaps/test-snapd-classic-service-hooks/meta/hooks/install create mode 100644 tests/lib/snaps/test-snapd-classic-service-hooks/meta/snap.yaml create mode 100755 tests/lib/snaps/test-snapd-complex-layout/bin/sh create mode 100644 tests/lib/snaps/test-snapd-complex-layout/meta/snap.yaml create mode 100644 tests/lib/snaps/test-snapd-complex-layout/node/bin/node create mode 100644 tests/lib/snaps/test-snapd-complex-layout/usr/bin/python2.7 create mode 100644 tests/lib/snaps/test-snapd-complex-layout/usr/bin/python3.8 create mode 100644 tests/lib/snaps/test-snapd-complex-layout/usr/lib/jvm/jre/bin/java create mode 100644 tests/lib/snaps/test-snapd-complex-layout/wrapper-scripts/exec-node.sh 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-circular1/bin/content-plug create mode 100644 tests/lib/snaps/test-snapd-content-circular1/import/.placeholder create mode 100644 tests/lib/snaps/test-snapd-content-circular1/meta/snap.yaml create mode 100755 tests/lib/snaps/test-snapd-content-circular2/bin/content-plug create mode 100644 tests/lib/snaps/test-snapd-content-circular2/import/.placeholder create mode 100644 tests/lib/snaps/test-snapd-content-circular2/meta/snap.yaml create mode 100755 tests/lib/snaps/test-snapd-content-mimic-plug/bin/sh create mode 100644 tests/lib/snaps/test-snapd-content-mimic-plug/dir/stuff-in-dir create mode 100644 tests/lib/snaps/test-snapd-content-mimic-plug/file create mode 100644 tests/lib/snaps/test-snapd-content-mimic-plug/meta/snap.yaml create mode 120000 tests/lib/snaps/test-snapd-content-mimic-plug/symlink create mode 100644 tests/lib/snaps/test-snapd-content-mimic-plug/symlink-target create mode 100755 tests/lib/snaps/test-snapd-content-mimic-slot/bin/sh create mode 100644 tests/lib/snaps/test-snapd-content-mimic-slot/meta/snap.yaml create mode 100644 tests/lib/snaps/test-snapd-content-mimic-slot/source/canary create mode 100755 tests/lib/snaps/test-snapd-content-plug-no-content-attr/bin/content-plug create mode 100644 tests/lib/snaps/test-snapd-content-plug-no-content-attr/import/.placeholder create mode 100644 tests/lib/snaps/test-snapd-content-plug-no-content-attr/meta/snap.yaml 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-no-content-attr/meta/snap.yaml create mode 100644 tests/lib/snaps/test-snapd-content-slot-no-content-attr/shared-content 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-core-migration.base-core/bin/sh create mode 100644 tests/lib/snaps/test-snapd-core-migration.base-core/meta/snap.yaml create mode 100755 tests/lib/snaps/test-snapd-core-migration.base-core18/bin/sh create mode 100644 tests/lib/snaps/test-snapd-core-migration.base-core18/meta/snap.yaml create mode 100644 tests/lib/snaps/test-snapd-cups-control-consumer/snapcraft.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 100644 tests/lib/snaps/test-snapd-daemon-user/Makefile create mode 100644 tests/lib/snaps/test-snapd-daemon-user/snapcraft.yaml create mode 100644 tests/lib/snaps/test-snapd-daemon-user/src/Makefile create mode 100644 tests/lib/snaps/test-snapd-daemon-user/src/chown.c create mode 120000 tests/lib/snaps/test-snapd-daemon-user/src/chown32.c create mode 100644 tests/lib/snaps/test-snapd-daemon-user/src/display.c create mode 100644 tests/lib/snaps/test-snapd-daemon-user/src/display.h create mode 120000 tests/lib/snaps/test-snapd-daemon-user/src/display32.c create mode 100644 tests/lib/snaps/test-snapd-daemon-user/src/drop-exec.c create mode 120000 tests/lib/snaps/test-snapd-daemon-user/src/drop-exec32.c create mode 100644 tests/lib/snaps/test-snapd-daemon-user/src/drop-syscall.c create mode 100644 tests/lib/snaps/test-snapd-daemon-user/src/drop-syscall32.c create mode 100644 tests/lib/snaps/test-snapd-daemon-user/src/drop.c create mode 120000 tests/lib/snaps/test-snapd-daemon-user/src/drop32.c create mode 100644 tests/lib/snaps/test-snapd-daemon-user/src/fchown.c create mode 120000 tests/lib/snaps/test-snapd-daemon-user/src/fchown32.c create mode 100644 tests/lib/snaps/test-snapd-daemon-user/src/fchownat.c create mode 120000 tests/lib/snaps/test-snapd-daemon-user/src/fchownat32.c create mode 100644 tests/lib/snaps/test-snapd-daemon-user/src/lchown.c create mode 120000 tests/lib/snaps/test-snapd-daemon-user/src/lchown32.c create mode 100644 tests/lib/snaps/test-snapd-daemon-user/src/setgid.c create mode 120000 tests/lib/snaps/test-snapd-daemon-user/src/setgid32.c create mode 100644 tests/lib/snaps/test-snapd-daemon-user/src/setregid.c create mode 120000 tests/lib/snaps/test-snapd-daemon-user/src/setregid32.c create mode 100644 tests/lib/snaps/test-snapd-daemon-user/src/setresgid.c create mode 120000 tests/lib/snaps/test-snapd-daemon-user/src/setresgid32.c create mode 100644 tests/lib/snaps/test-snapd-daemon-user/src/setresuid.c create mode 120000 tests/lib/snaps/test-snapd-daemon-user/src/setresuid32.c create mode 100644 tests/lib/snaps/test-snapd-daemon-user/src/setreuid.c create mode 120000 tests/lib/snaps/test-snapd-daemon-user/src/setreuid32.c create mode 100644 tests/lib/snaps/test-snapd-daemon-user/src/setuid.c create mode 120000 tests/lib/snaps/test-snapd-daemon-user/src/setuid32.c create mode 100755 tests/lib/snaps/test-snapd-dbus-consumer/consumer.py create mode 100644 tests/lib/snaps/test-snapd-dbus-consumer/snapcraft.yaml create mode 100755 tests/lib/snaps/test-snapd-dbus-provider/consumer.py create mode 100644 tests/lib/snaps/test-snapd-dbus-provider/provider.py create mode 100644 tests/lib/snaps/test-snapd-dbus-provider/snapcraft.yaml create mode 100755 tests/lib/snaps/test-snapd-dbus-provider/wrapper 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-dbus-service-conflicting/bin/server.sh create mode 100644 tests/lib/snaps/test-snapd-dbus-service-conflicting/meta/snap.yaml create mode 100755 tests/lib/snaps/test-snapd-dbus-service/bin/test-snapd-dbus-service create mode 100644 tests/lib/snaps/test-snapd-dbus-service/setup.py create mode 100644 tests/lib/snaps/test-snapd-dbus-service/snapcraft.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-devpts/bin/openpty create mode 100755 tests/lib/snaps/test-snapd-devpts/bin/useptmx create mode 100644 tests/lib/snaps/test-snapd-devpts/meta/snap.yaml create mode 100644 tests/lib/snaps/test-snapd-eds/calendar.c create mode 100644 tests/lib/snaps/test-snapd-eds/contacts.c create mode 100644 tests/lib/snaps/test-snapd-eds/meson.build create mode 100644 tests/lib/snaps/test-snapd-eds/snap/snapcraft.yaml create mode 100644 tests/lib/snaps/test-snapd-epoch-1/meta/snap.yaml create mode 100644 tests/lib/snaps/test-snapd-epoch-2/meta/snap.yaml create mode 100755 tests/lib/snaps/test-snapd-event/bin/read-evdev-device create mode 100644 tests/lib/snaps/test-snapd-event/meta/snap.yaml create mode 100755 tests/lib/snaps/test-snapd-framebuffer/bin/read create mode 100755 tests/lib/snaps/test-snapd-framebuffer/bin/write create mode 100644 tests/lib/snaps/test-snapd-framebuffer/meta/snap.yaml create mode 100644 tests/lib/snaps/test-snapd-fuse-consumer/Makefile create mode 100644 tests/lib/snaps/test-snapd-fuse-consumer/snapcraft.yaml create mode 100755 tests/lib/snaps/test-snapd-fwupd/bin/get-version.sh create mode 100644 tests/lib/snaps/test-snapd-fwupd/meta/snap.yaml create mode 100644 tests/lib/snaps/test-snapd-go-webserver/main.go create mode 100644 tests/lib/snaps/test-snapd-go-webserver/snapcraft.yaml create mode 100644 tests/lib/snaps/test-snapd-gpio-memory-control/Makefile create mode 100644 tests/lib/snaps/test-snapd-gpio-memory-control/gpiomem.c create mode 100644 tests/lib/snaps/test-snapd-gpio-memory-control/snapcraft.yaml create mode 100755 tests/lib/snaps/test-snapd-hardware-random-control/bin/check create mode 100644 tests/lib/snaps/test-snapd-hardware-random-control/meta/snap.yaml create mode 100755 tests/lib/snaps/test-snapd-hardware-random-observe/bin/check create mode 100644 tests/lib/snaps/test-snapd-hardware-random-observe/meta/snap.yaml create mode 100755 tests/lib/snaps/test-snapd-health/health create mode 100755 tests/lib/snaps/test-snapd-health/meta/hooks/check-health create mode 100755 tests/lib/snaps/test-snapd-health/meta/hooks/configure create mode 100644 tests/lib/snaps/test-snapd-health/meta/snap.yaml create mode 100644 tests/lib/snaps/test-snapd-hello-classic/Makefile create mode 100644 tests/lib/snaps/test-snapd-hello-classic/snapcraft.yaml create mode 100644 tests/lib/snaps/test-snapd-hello-classic/test-snapd-hello-classic.c 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-illegal-system-username/bin/sh create mode 100644 tests/lib/snaps/test-snapd-illegal-system-username/meta/snap.yaml create mode 100644 tests/lib/snaps/test-snapd-invalid-base/meta/snap.yaml create mode 100755 tests/lib/snaps/test-snapd-just-beta/snap-name create mode 100644 tests/lib/snaps/test-snapd-just-beta/snapcraft.yaml create mode 100755 tests/lib/snaps/test-snapd-just-edge/snap-name create mode 100644 tests/lib/snaps/test-snapd-just-edge/snapcraft.yaml create mode 100644 tests/lib/snaps/test-snapd-kernel-module-control-consumer/snapcraft.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-libvirt-consumer/bin/machine-down create mode 100755 tests/lib/snaps/test-snapd-libvirt-consumer/bin/machine-up create mode 100644 tests/lib/snaps/test-snapd-libvirt-consumer/snapcraft.yaml create mode 100644 tests/lib/snaps/test-snapd-libvirt-consumer/vm/ping-unikernel.xml create mode 100755 tests/lib/snaps/test-snapd-location-control-provider/consumer create mode 100644 tests/lib/snaps/test-snapd-location-control-provider/provider.py create mode 100644 tests/lib/snaps/test-snapd-location-control-provider/snapcraft.yaml create mode 100755 tests/lib/snaps/test-snapd-location-control-provider/wrapper create mode 100755 tests/lib/snaps/test-snapd-lp-1803535/bin/sh create mode 100644 tests/lib/snaps/test-snapd-lp-1803535/etc/OpenCL/vendors/foo.icd create mode 100644 tests/lib/snaps/test-snapd-lp-1803535/meta/snap.yaml create mode 100644 tests/lib/snaps/test-snapd-mokutil/snapcraft.yaml create mode 100755 tests/lib/snaps/test-snapd-multi-service/bin/start create mode 100644 tests/lib/snaps/test-snapd-multi-service/meta/snap.yaml create mode 100755 tests/lib/snaps/test-snapd-netlink-audit/bin/bind create mode 100644 tests/lib/snaps/test-snapd-netlink-audit/meta/snap.yaml create mode 100755 tests/lib/snaps/test-snapd-netlink-connector/bin/bind create mode 100644 tests/lib/snaps/test-snapd-netlink-connector/meta/snap.yaml create mode 100755 tests/lib/snaps/test-snapd-network-status-client/bin/get-connectivity.sh create mode 100644 tests/lib/snaps/test-snapd-network-status-client/meta/snap.yaml create mode 100644 tests/lib/snaps/test-snapd-number-version/meta/snap.yaml create mode 100755 tests/lib/snaps/test-snapd-openvswitch-consumer/bin/ovs-vsctl create mode 100644 tests/lib/snaps/test-snapd-openvswitch-consumer/snapcraft.yaml create mode 100644 tests/lib/snaps/test-snapd-packagekit/snapcraft.yaml create mode 100755 tests/lib/snaps/test-snapd-password-manager-service-consumer/bin/secret-tool create mode 100644 tests/lib/snaps/test-snapd-password-manager-service-consumer/snapcraft.yaml create mode 100755 tests/lib/snaps/test-snapd-physical-memory-observe/bin/head-mem create mode 100644 tests/lib/snaps/test-snapd-physical-memory-observe/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/snap.yaml create mode 100755 tests/lib/snaps/test-snapd-policy-app-provider-classic/bin/run create mode 100644 tests/lib/snaps/test-snapd-policy-app-provider-classic/meta/snap.yaml create mode 100755 tests/lib/snaps/test-snapd-policy-app-provider-core/bin/run create mode 100644 tests/lib/snaps/test-snapd-policy-app-provider-core/meta/snap.yaml create mode 100644 tests/lib/snaps/test-snapd-portal-client/client.py create mode 100644 tests/lib/snaps/test-snapd-portal-client/setup.py create mode 100644 tests/lib/snaps/test-snapd-portal-client/snapcraft.yaml create mode 100644 tests/lib/snaps/test-snapd-private/meta/snap.yaml create mode 100644 tests/lib/snaps/test-snapd-profiler-core18/config.ini create mode 100644 tests/lib/snaps/test-snapd-profiler-core18/profiler.py create mode 100644 tests/lib/snaps/test-snapd-profiler-core18/snapcraft.yaml create mode 100644 tests/lib/snaps/test-snapd-profiler/config.ini create mode 100644 tests/lib/snaps/test-snapd-profiler/profiler.py create mode 100644 tests/lib/snaps/test-snapd-profiler/snapcraft.yaml create mode 100644 tests/lib/snaps/test-snapd-public/meta/snap.yaml create mode 100644 tests/lib/snaps/test-snapd-pulseaudio/Makefile create mode 100755 tests/lib/snaps/test-snapd-pulseaudio/files/bin/pawrap create mode 100644 tests/lib/snaps/test-snapd-pulseaudio/snapcraft.yaml create mode 100644 tests/lib/snaps/test-snapd-pulseaudio/src/Makefile create mode 100644 tests/lib/snaps/test-snapd-pulseaudio/src/parec-simple.c create mode 100644 tests/lib/snaps/test-snapd-python-webserver/index.html create mode 100755 tests/lib/snaps/test-snapd-python-webserver/server.py create mode 100644 tests/lib/snaps/test-snapd-python-webserver/snapcraft.yaml 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 100644 tests/lib/snaps/test-snapd-rsync/snapcraft.yaml create mode 100755 tests/lib/snaps/test-snapd-service-start-timeout/forking.sh create mode 100644 tests/lib/snaps/test-snapd-service-start-timeout/meta/snap.yaml create mode 100755 tests/lib/snaps/test-snapd-service-stop-timeout/forking.sh create mode 100644 tests/lib/snaps/test-snapd-service-stop-timeout/meta/snap.yaml create mode 100755 tests/lib/snaps/test-snapd-service-stop-timeout/staaap.sh create mode 100755 tests/lib/snaps/test-snapd-service-try-v1/bin/service create mode 100644 tests/lib/snaps/test-snapd-service-try-v1/meta/snap.yaml create mode 100755 tests/lib/snaps/test-snapd-service-try-v2/bin/service create mode 100644 tests/lib/snaps/test-snapd-service-try-v2/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-watchdog/bin/direct create mode 100644 tests/lib/snaps/test-snapd-service-watchdog/meta/snap.yaml create mode 100755 tests/lib/snaps/test-snapd-service-writer/bin/start create mode 100755 tests/lib/snaps/test-snapd-service-writer/meta/hooks/configure create mode 100644 tests/lib/snaps/test-snapd-service-writer/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 100644 tests/lib/snaps/test-snapd-setpriority/Makefile create mode 100644 tests/lib/snaps/test-snapd-setpriority/setpriority.c create mode 100644 tests/lib/snaps/test-snapd-setpriority/snapcraft.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/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-sleep-install/meta/hooks/configure create mode 100644 tests/lib/snaps/test-snapd-sleep-install/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-statx/bin/statx.py create mode 100644 tests/lib/snaps/test-snapd-statx/meta/snap.yaml create mode 100755 tests/lib/snaps/test-snapd-svcs-disable-install-hook/bin/forking.sh create mode 100755 tests/lib/snaps/test-snapd-svcs-disable-install-hook/bin/simple.sh create mode 100755 tests/lib/snaps/test-snapd-svcs-disable-install-hook/meta/hooks/install create mode 100644 tests/lib/snaps/test-snapd-svcs-disable-install-hook/meta/snap.yaml create mode 100755 tests/lib/snaps/test-snapd-svcs-disable-refresh-hook/bin/forking.sh create mode 100755 tests/lib/snaps/test-snapd-svcs-disable-refresh-hook/bin/simple.sh create mode 100755 tests/lib/snaps/test-snapd-svcs-disable-refresh-hook/meta/hooks/post-refresh create mode 100644 tests/lib/snaps/test-snapd-svcs-disable-refresh-hook/meta/snap.yaml create mode 100755 tests/lib/snaps/test-snapd-system-observe-consumer/consumer.py create mode 100755 tests/lib/snaps/test-snapd-system-observe-consumer/dbus-introspect.py create mode 100644 tests/lib/snaps/test-snapd-system-observe-consumer/snapcraft.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/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-tuntap/bin/tuntap.py create mode 100644 tests/lib/snaps/test-snapd-tuntap/meta/snap.yaml create mode 100755 tests/lib/snaps/test-snapd-udev-input-subsystem/bin/read-evdev-kbd create mode 100644 tests/lib/snaps/test-snapd-udev-input-subsystem/meta/snap.yaml create mode 100644 tests/lib/snaps/test-snapd-udisks2/snapcraft.yaml create mode 100755 tests/lib/snaps/test-snapd-udisks2/udisksctl create mode 100644 tests/lib/snaps/test-snapd-uhid/Makefile create mode 100644 tests/lib/snaps/test-snapd-uhid/snapcraft.yaml create mode 100644 tests/lib/snaps/test-snapd-uhid/uhid-test.c create mode 100755 tests/lib/snaps/test-snapd-unknown-interfaces/bin/sh create mode 100644 tests/lib/snaps/test-snapd-unknown-interfaces/meta/snap.yaml create mode 100644 tests/lib/snaps/test-snapd-upower-observe-consumer/snapcraft.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 100644 tests/lib/snaps/test-snapd-validate-container-failures/bin/bar create mode 100755 tests/lib/snaps/test-snapd-validate-container-failures/bin/foo create mode 100644 tests/lib/snaps/test-snapd-validate-container-failures/comp.sh create mode 120000 tests/lib/snaps/test-snapd-validate-container-failures/hell/bar create mode 120000 tests/lib/snaps/test-snapd-validate-container-failures/hell/bar -> baz create mode 120000 tests/lib/snaps/test-snapd-validate-container-failures/hell/bar -> baz -> qux create mode 120000 tests/lib/snaps/test-snapd-validate-container-failures/hell/bar -> qux create mode 120000 tests/lib/snaps/test-snapd-validate-container-failures/hell/baz create mode 120000 tests/lib/snaps/test-snapd-validate-container-failures/hell/baz -> qux create mode 120000 tests/lib/snaps/test-snapd-validate-container-failures/hell/foo create mode 120000 tests/lib/snaps/test-snapd-validate-container-failures/hell/foo -> bar create mode 120000 tests/lib/snaps/test-snapd-validate-container-failures/hell/foo -> bar -> baz create mode 120000 tests/lib/snaps/test-snapd-validate-container-failures/hell/foo -> bar -> qux create mode 120000 tests/lib/snaps/test-snapd-validate-container-failures/hell/foo -> baz create mode 120000 tests/lib/snaps/test-snapd-validate-container-failures/hell/foo -> baz -> qux create mode 120000 tests/lib/snaps/test-snapd-validate-container-failures/hell/foo -> qux create mode 120000 tests/lib/snaps/test-snapd-validate-container-failures/hell/qux create mode 100644 tests/lib/snaps/test-snapd-validate-container-failures/meta/hooks/what create mode 100644 tests/lib/snaps/test-snapd-validate-container-failures/meta/snap.yaml create mode 100644 tests/lib/snaps/test-snapd-validate-container-failures/meta/unreadable 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-nc/meta/hooks/configure create mode 100644 tests/lib/snaps/test-snapd-with-configure-nc/meta/snap.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 100755 tests/lib/snaps/test-snapd-xdg-autostart/bin/foobar create mode 100644 tests/lib/snaps/test-snapd-xdg-autostart/meta/snap.yaml create mode 100755 tests/lib/snaps/test-snapd-xdg-settings/bin/browser create mode 100755 tests/lib/snaps/test-snapd-xdg-settings/bin/xdg-settings-wrapper create mode 100644 tests/lib/snaps/test-snapd-xdg-settings/meta/gui/browser.desktop create mode 100644 tests/lib/snaps/test-snapd-xdg-settings/meta/snap.yaml create mode 100755 tests/lib/snaps/test-strict-cgroup/bin/read-dev create mode 100644 tests/lib/snaps/test-strict-cgroup/meta/snap.yaml create mode 100644 tests/lib/spread-funcs.sh create mode 100755 tests/lib/state.sh create mode 100644 tests/lib/store.sh create mode 100644 tests/lib/successful_login.exp create mode 100644 tests/lib/systemd-escape/main.go create mode 100644 tests/lib/systemd.sh 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/any-python create mode 100755 tests/lib/tools/apt-state 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/mountinfo.query create mode 100755 tests/lib/tools/nested-state create mode 100755 tests/lib/tools/not create mode 100755 tests/lib/tools/os.query create mode 100755 tests/lib/tools/retry create mode 100755 tests/lib/tools/sha3-384 create mode 100755 tests/lib/tools/snapd.tool create mode 100755 tests/lib/tools/snaps-state create mode 100644 tests/lib/tools/suite/apt-state/task.yaml 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/retry-tool/task.yaml create mode 100644 tests/lib/tools/suite/tests.backup/task.yaml create mode 100644 tests/lib/tools/suite/tests.cleanup/task.yaml create mode 100644 tests/lib/tools/suite/tests.invariant/task.yaml create mode 100644 tests/lib/tools/suite/tests.pkgs/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.invariant 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/to-one-line create mode 100755 tests/lib/tools/user-state create mode 100755 tests/lib/tools/version-compare create mode 100644 tests/lib/uc20-create-partitions/main.go 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 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 100644 tests/main/apt-hooks/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-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/base-invalid-type/task.yaml create mode 100644 tests/main/base-migration/task.yaml create mode 100644 tests/main/base-none/task.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/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/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/task.sh create mode 100644 tests/main/cgroup-devices/task.yaml create mode 100755 tests/main/cgroup-devices/test-snapd-service/bin/service create mode 100644 tests/main/cgroup-devices/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 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/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 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/config-versions/task.yaml create mode 100644 tests/main/configure-hook-with-network-control/task.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/core-snap-not-test-test/task.yaml create mode 100644 tests/main/core-snap-refresh/task.yaml create mode 100644 tests/main/core16-base/task.yaml create mode 100644 tests/main/core16-provided-by-core/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 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/debs/task.yaml create mode 100644 tests/main/debug-confinement/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-timeout/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 100755 tests/main/fake-netplan-apply/netplan-info.sh create mode 100644 tests/main/fake-netplan-apply/snapcraft.yaml 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/health/task.yaml create mode 100644 tests/main/help/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-fontconfig-cache-gen/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/task.yaml create mode 100644 tests/main/install-socket-activation/task.yaml create mode 100644 tests/main/install-store-laaaarge/task.yaml create mode 100644 tests/main/install-store/task.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 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 100644 tests/main/interfaces-browser-support/task.yaml create mode 100644 tests/main/interfaces-calendar-service/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 100644 tests/main/interfaces-content-default-provider/task.yaml create mode 100644 tests/main/interfaces-content-empty-content-attr/task.yaml create mode 100644 tests/main/interfaces-content-mimic/task.yaml 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/task.yaml create mode 100644 tests/main/interfaces-cups/task.yaml create mode 100755 tests/main/interfaces-cups/test-snapd-consumer/bin/sh create mode 100644 tests/main/interfaces-cups/test-snapd-consumer/meta/snap.yaml create mode 100755 tests/main/interfaces-cups/test-snapd-provider/bin/sh create mode 100644 tests/main/interfaces-cups/test-snapd-provider/meta/snap.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/task.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 100644 tests/main/interfaces-firewall-control/task.yaml create mode 100644 tests/main/interfaces-framebuffer/task.yaml create mode 100644 tests/main/interfaces-fuse-support/task.yaml create mode 100644 tests/main/interfaces-fwupd-classic/task.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 100644 tests/main/interfaces-hardware-observe/task.yaml create mode 100644 tests/main/interfaces-hardware-random-control/task.yaml create mode 100644 tests/main/interfaces-hardware-random-observe/task.yaml create mode 100644 tests/main/interfaces-home/task.yaml create mode 100644 tests/main/interfaces-hooks-misbehaving/task.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-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-kvm/task.yaml create mode 100644 tests/main/interfaces-libvirt/task.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 100644 tests/main/interfaces-mount-observe/task.yaml create mode 100644 tests/main/interfaces-netlink-audit/task.yaml create mode 100644 tests/main/interfaces-netlink-connector/task.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 100644 tests/main/interfaces-network-control/task.yaml create mode 100644 tests/main/interfaces-network-manager/task.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 100644 tests/main/interfaces-network/task.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 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-shutdown-introspection/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 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 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-upower-observe/test-snapd-upower/bin/upowerd.sh create mode 100644 tests/main/interfaces-upower-observe/test-snapd-upower/snapcraft.yaml 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 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/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/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/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/os.query/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-grub-core18/task.yaml create mode 100644 tests/main/prepare-image-grub/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/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/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/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-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-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 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/sanitycheck/task.yaml create mode 100644 tests/main/searching/task.yaml create mode 100644 tests/main/seccomp-statx/task.yaml create mode 100644 tests/main/security-apparmor/task.yaml create mode 100644 tests/main/security-dev-input-event-denied/task.yaml create mode 100644 tests/main/security-device-cgroups-classic/task.yaml create mode 100644 tests/main/security-device-cgroups-devmode/task.yaml create mode 100644 tests/main/security-device-cgroups-jailmode/task.yaml create mode 100644 tests/main/security-device-cgroups-serial-port/task.yaml create mode 100644 tests/main/security-device-cgroups-strict/task.yaml create mode 100644 tests/main/security-device-cgroups/task.yaml create mode 100644 tests/main/security-devpts/task.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 100644 tests/main/selinux-classic-confinement/task.yaml create mode 100644 tests/main/selinux-clean/task.yaml create mode 100644 tests/main/selinux-data-context/task.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 100644 tests/main/services-disable-refresh-hook/task.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 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 100644 tests/main/services-refresh-mode/task.yaml create mode 100644 tests/main/services-snapctl/task.yaml create mode 100644 tests/main/services-start-timeout/task.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 100644 tests/main/services-timer/task.yaml create mode 100644 tests/main/services-watchdog/task.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-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/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-state/task.yaml create mode 100644 tests/main/snap-debug-timings/task.yaml create mode 100644 tests/main/snap-device-helper-nvme/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/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 100644 tests/main/snap-pack/task.yaml create mode 100644 tests/main/snap-readme/task.yaml create mode 100644 tests/main/snap-remove-not-mounted/task.yaml create mode 100644 tests/main/snap-repair/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-gdbserver/task.yaml create mode 100644 tests/main/snap-run-hook/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 100644 tests/main/snap-run/task.yaml 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 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-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 100644 tests/main/snap-userd-reexec/task.yaml create mode 100644 tests/main/snap-validate-basic/task.yaml create mode 100644 tests/main/snap-wait/task.yaml create mode 100644 tests/main/snapctl-from-snap/task.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-certs/task.yaml create mode 100644 tests/main/snapd-go-socket-activated/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-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-without-core/task.yaml create mode 100644 tests/main/snaps-state/task.yaml create mode 100644 tests/main/snapshot-basic/task.yaml create mode 100644 tests/main/snapshot-cross-revno/task.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/stale-base-snap/task.yaml create mode 100644 tests/main/static/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 100644 tests/main/system-usernames-install-twice/task.yaml create mode 100644 tests/main/system-usernames-missing-user/task.yaml create mode 100644 tests/main/system-usernames/task.yaml create mode 100644 tests/main/systemd-service/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 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 100644 tests/main/unhandled-task/task.yaml create mode 100644 tests/main/upgrade-from-2.15/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/vitality/task.yaml create mode 100644 tests/main/whoami/task.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 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/core-revert/task.yaml create mode 100644 tests/nested/core/core-snap-refresh-on-core/task.yaml create mode 100644 tests/nested/core/extra-snaps-assertions/task.yaml create mode 100644 tests/nested/core/hotplug/task.yaml create mode 100644 tests/nested/core/image-build/task.yaml create mode 100644 tests/nested/core20/basic/task.yaml create mode 100644 tests/nested/core20/degraded/task.yaml create mode 100755 tests/nested/core20/gadget-reseal/manip_gadget.py create mode 100644 tests/nested/core20/gadget-reseal/task.yaml create mode 100644 tests/nested/core20/kernel-failover/task.yaml create mode 100644 tests/nested/core20/kernel-reseal/task.yaml create mode 100644 tests/nested/core20/tpm/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/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/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-save/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/grade-signed-above-testkeys-boot/defaults.yaml create mode 100755 tests/nested/manual/grade-signed-above-testkeys-boot/prepare-device create mode 100644 tests/nested/manual/grade-signed-above-testkeys-boot/task.yaml create mode 100644 tests/nested/manual/grade-signed-cloud-init-testkeys/defaults.yaml create mode 100755 tests/nested/manual/grade-signed-cloud-init-testkeys/prepare-device create mode 100644 tests/nested/manual/grade-signed-cloud-init-testkeys/task.yaml create mode 100644 tests/nested/manual/minimal-smoke/task.yaml create mode 100644 tests/nested/manual/preseed/task.yaml create mode 100644 tests/nested/manual/refresh-revert-fundamentals/task.yaml create mode 100644 tests/nested/manual/snapd-refresh-from-old/task.yaml create mode 100644 tests/nested/manual/uc20-fde-hooks/task.yaml create mode 100644 tests/nested/manual/uc20-storage-safety/task.yaml create mode 100644 tests/nightly/classic-ubuntu-core-transition-auth/task.yaml create mode 100644 tests/nightly/classic-ubuntu-core-transition-two-cores/task.yaml create mode 100644 tests/nightly/classic-ubuntu-core-transition/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/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 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 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/.gitkeep 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-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/rhbz-1584461/task.yaml create mode 100644 tests/regression/rhbz-1708991/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/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 100755 tests/util/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/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/lowlevel.go create mode 100644 testutil/lowlevel_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 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/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/portal_launcher_test.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 vendor/vendor.json 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/services.go create mode 100644 wrappers/services_gen_test.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..a777cf13 --- /dev/null +++ b/.clang-format @@ -0,0 +1,3 @@ +BasedOnStyle: Google +IndentWidth: 4 +ColumnLimit: 120 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..50d4760e --- /dev/null +++ b/.github/workflows/cla-check.yaml @@ -0,0 +1,28 @@ +name: cla-check +on: + # Only run on pull requests: not pushes + pull_request: + branches: [ "master", "release/**" ] + +jobs: + cla-check: + runs-on: self-hosted + steps: + - name: Install dependencies + run: | + # sudo apt-get update + # sudo apt-get install python-launchpadlib + # TODO: make this conditional on self-hosted or not + echo "dependencies are baked into unprivileged test containers" + - name: Checkout code + uses: actions/checkout@v2 + with: + # The cla_check script reads git commit history, so can't + # use a shallow checkout. + fetch-depth: 0 + # ensure we pull PR /head, not autogenerated /merge commit + ref: ${{ github.event.pull_request.head.sha }} + - name: Fetching base ref ${{ github.base_ref }} + run: git fetch origin ${{ github.base_ref }}:${{ github.base_ref }} + - name: CLA check + run: ./tests/lib/cla_check.py "${{ github.base_ref }}..HEAD" diff --git a/.github/workflows/macos-sanity.yaml b/.github/workflows/macos-sanity.yaml new file mode 100644 index 00000000..fad54c6c --- /dev/null +++ b/.github/workflows/macos-sanity.yaml @@ -0,0 +1,48 @@ +name: MacOS sanity checks +on: + # Only run on pull requests: not pushes + pull_request: + branches: [ "master", "release/**" ] + +jobs: + macos-sanity: + runs-on: macos-latest + env: + GOPATH: ${{ github.workspace }} + GO111MODULE: off + steps: + - uses: actions/setup-go@v2 + with: + go-version: "1.14.x" + + - name: Checkout code + uses: actions/checkout@v2 + with: + # 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: Install squashfs from homebrew + run: | + brew install squashfs + + - name: Install Go package dependencies + working-directory: ./src/github.com/snapcore/snapd + run: | + ./mkversion.sh + ./get-deps.sh + # extra dependency on darwin: + go get golang.org/x/sys/unix + + - name: Build sanity checks + run: | + go build -tags nosecboot -o /tmp/snp github.com/snapcore/snapd/cmd/snap + + - name: Runtime sanity checks + working-directory: ./src/github.com/snapcore/snapd + run: | + /tmp/snp download hello + /tmp/snp version + # TODO: homebrew appears to be broken, brew install of squashfs fails + # and goes unnoticed by travis + if command -v mksquashfs; then /tmp/snp pack tests/lib/snaps/test-snapd-tools/ /tmp ; fi diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 00000000..f317a0ba --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,314 @@ +name: Tests +on: + pull_request: + branches: [ "master", "release/**" ] + push: + branches: [ "release/**" ] + +jobs: + snap-builds: + runs-on: ubuntu-16.04 + steps: + - name: Checkout code + uses: actions/checkout@v2 + - name: Cache snapd snap build status + id: cache-snapd-build-status + uses: actions/cache@v1 + with: + path: "${{ github.workspace }}/.test-results" + key: "${{ github.run_id }}-${{ github.job }}-results" + - name: Check cached snap build + id: cached-results + run: | + CACHE_RESULT_STAMP="${{ github.workspace }}/.test-results/snap-build-success" + echo "CACHE_RESULT_STAMP=$CACHE_RESULT_STAMP" >> $GITHUB_ENV + if [ -e "$CACHE_RESULT_STAMP" ]; then + has_cached_snap=0 + while read name; do + has_cached_snap=1 + # bring back artifacts from the cache + cp -v "$name" "${{ github.workspace }}" + done < <(find "$(dirname $CACHE_RESULT_STAMP)" -name "*.snap") + if [ "$has_cached_snap" = "1" ]; then + # we have restored an artifact from the cache + echo "::set-output name=already-ran::true" + fi + fi + - name: Build snapd snap + if: steps.cached-results.outputs.already-ran != 'true' + uses: snapcore/action-build@v1 + - name: Cache built artifact + run: | + mkdir -p $(dirname "$CACHE_RESULT_STAMP") + cp -v *.snap "$(dirname $CACHE_RESULT_STAMP)/" + - name: Uploading snapd snap artifact + uses: actions/upload-artifact@v2 + with: + name: snap-files + path: "*.snap" + - name: Mark successful snap build + run: | + mkdir -p $(dirname "$CACHE_RESULT_STAMP") + touch "$CACHE_RESULT_STAMP" + + unit-tests: + runs-on: ubuntu-16.04 + env: + GOPATH: ${{ github.workspace }} + GO111MODULE: off + # 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 + GOROOT: "" + # XXX: compat env for "check-pr-title.py" in "run-checks", can go + # once we switch away from that. Note that we cannot currently + # use a github action check (like deepakputhraya/action-pr-title) + # because an update of the PR title in the github UI is not visible + # to the github action. + TRAVIS_PULL_REQUEST: ${{ github.event.number }} + strategy: + # we cache successful runs so it's fine to keep going + fail-fast: false + matrix: + gochannel: + - 1.9 + - latest/stable + steps: + - name: Checkout code + uses: actions/checkout@v2 + with: + # 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: Cache Debian dependencies + id: cache-deb-downloads + uses: actions/cache@v1 + with: + path: /var/cache/apt + key: var-cache-apt-{{ hashFiles('**/debian/control') }} + - name: Run "apt update" + run: | + sudo apt update + - name: Download Debian dependencies + if: steps.cache-deb-downloads.outputs.cache-hit != 'true' + run: | + sudo apt clean + sudo apt build-dep -d -y ${{ github.workspace }}/src/github.com/snapcore/snapd + + - name: Cache snapd test results + id: cache-snapd-test-results + uses: actions/cache@v1 + with: + path: "${{ github.workspace }}/.test-results" + # must include matrix or things get racy, i.e. when latest/edge + # finishes after 1.9 it overrides the results from 1.9 + key: "${{ github.run_id }}-${{ github.job }}-${{ matrix.gochannel }}-results" + - name: Check cached test results + id: cached-results + run: | + CACHE_RESULT_STAMP="${{ github.workspace }}/.test-results/${{ matrix.gochannel }}-success" + echo "CACHE_RESULT_STAMP=$CACHE_RESULT_STAMP" >> $GITHUB_ENV + if [ -e "$CACHE_RESULT_STAMP" ]; then + echo "::set-output name=already-ran::true" + fi + - name: Install Debian dependencies + if: steps.cached-results.outputs.cached-resulsts != 'true' + run: | + 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 + if: steps.cached-results.outputs.already-ran != 'true' + run: | + sudo snap install --classic --channel=${{ matrix.gochannel }} go + - name: Install ShellCheck as a snap + if: steps.cached-results.outputs.already-ran != 'true' + run: | + sudo apt-get remove --purge shellcheck + sudo snap install shellcheck + - name: Install govendor + run: go get -u github.com/kardianos/govendor + - name: Cache Go dependencies + id: cache-go-govendor + uses: actions/cache@v1 + with: + path: ${{ github.workspace }}/.cache/govendor + key: go-govendor-{{ hashFiles('**/vendor.json') }} + - name: Get Go dependencies + run: cd ${{ github.workspace }}/src/github.com/snapcore/snapd && ${{ github.workspace }}/bin/govendor sync + - name: Run static checks + if: steps.cached-results.outputs.already-ran != 'true' + run: | + cd ${{ github.workspace }}/src/github.com/snapcore/snapd || exit 1 + # run gofmt checks only with Go 1.9 and 1.10 + if ! echo "${{ matrix.gochannel }}" | grep -E '1\.(9|10)' ; then + # and skip with other versions + export SKIP_GOFMT=1 + echo "Formatting checks will be skipped due to the use of Go version ${{ matrix.gochannel }}" + fi + ./run-checks --static + - name: Build C + if: steps.cached-results.outputs.already-ran != 'true' + run: | + cd ${{ github.workspace }}/src/github.com/snapcore/snapd/cmd/ + ./autogen.sh + make -j2 + - name: Build Go + if: steps.cached-results.outputs.already-ran != 'true' + run: | + go build github.com/snapcore/snapd/... + - name: Test C + if: steps.cached-results.outputs.already-ran != 'true' + run: | + cd ${{ github.workspace }}/src/github.com/snapcore/snapd/cmd/ && make check + - name: Test Go + if: steps.cached-results.outputs.already-ran != 'true' + run: | + cd ${{ github.workspace }}/src/github.com/snapcore/snapd || exit 1 + ./run-checks --unit + - name: Test Go (nosecboot) + if: steps.cached-results.outputs.already-ran != 'true' + 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: Cache successful run + run: | + mkdir -p $(dirname "$CACHE_RESULT_STAMP") + touch "$CACHE_RESULT_STAMP" + + spread: + needs: [unit-tests] + 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: + - amazon-linux-2-64 + - arch-linux-64 + - centos-7-64 + - centos-8-64 + - debian-9-64 + - debian-sid-64 + - fedora-32-64 + - fedora-33-64 + - opensuse-15.1-64 + - opensuse-15.2-64 + - opensuse-tumbleweed-64 + - ubuntu-14.04-64 + - ubuntu-16.04-32 + - ubuntu-16.04-64 + - ubuntu-18.04-64 + - ubuntu-20.04-64 + - ubuntu-20.10-64 + - ubuntu-core-16-64 + - ubuntu-core-18-64 + - ubuntu-core-20-64 + - ubuntu-secboot-20.04-64 + steps: + - name: Checkout code + uses: actions/checkout@v2 + with: + # spread uses tags as delta reference + fetch-depth: 0 + - name: Cache snapd test results + id: cache-snapd-test-results + uses: actions/cache@v1 + with: + path: "${{ github.workspace }}/.test-results" + key: "${{ github.run_id }}-${{ github.job }}-${{ matrix.system }}-results" + - name: Check cached test results + id: cached-results + run: | + CACHE_RESULT_STAMP="${{ github.workspace }}/.test-results/${{ matrix.system }}-success" + echo "CACHE_RESULT_STAMP=$CACHE_RESULT_STAMP" >> $GITHUB_ENV + if [ -e "$CACHE_RESULT_STAMP" ]; then + echo "::set-output name=already-ran::true" + fi + - name: Run spread tests + if: "!contains(github.event.pull_request.labels.*.name, 'Skip spread') && steps.cached-results.outputs.already-ran != 'true'" + 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" + spread -abend google:${{ matrix.system }}:tests/... + - name: Cache successful run + run: | + mkdir -p $(dirname "$CACHE_RESULT_STAMP") + touch "$CACHE_RESULT_STAMP" + - 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 + + spread-nested: + needs: [unit-tests] + 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-20.10-64 + steps: + - name: Checkout code + uses: actions/checkout@v2 + - name: Cache snapd test results + id: cache-snapd-test-results + uses: actions/cache@v1 + with: + path: "${{ github.workspace }}/.test-results" + key: "${{ github.run_id }}-${{ github.job }}-${{ matrix.system }}-nested-results" + - name: Check cached test results + id: cached-results + run: | + CACHE_RESULT_STAMP="${{ github.workspace }}/.test-results/${{ matrix.system }}-nested-success" + echo "CACHE_RESULT_STAMP=$CACHE_RESULT_STAMP" >> $GITHUB_ENV + if [ -e "$CACHE_RESULT_STAMP" ]; then + echo "::set-output name=already-ran::true" + fi + - 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(github.event.pull_request.labels.*.name, 'Run nested') || contains(github.ref, 'refs/heads/release/')) && steps.cached-results.outputs.already-ran != 'true'" + 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 + spread -abend google-nested:${{ matrix.system }}:tests/nested/... + - name: Cache successful run + run: | + mkdir -p $(dirname "$CACHE_RESULT_STAMP") + touch "$CACHE_RESULT_STAMP" + - 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..d5ded9b3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,88 @@ +./share +tags +.coverage +snapdtool/version_generated.go +cmd/version_generated.go +cmd/VERSION +*~ +*.swp +.vscode/ +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-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-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 \ No newline at end of file diff --git a/.mailmap b/.mailmap new file mode 100644 index 00000000..70c2d525 --- /dev/null +++ b/.mailmap @@ -0,0 +1,2 @@ +Sergio Cazzolato sergio-j-cazzolato +John R. Lenton John Lenton 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/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..712c273e --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,27 @@ +Before contributing you should sign [Canonical's contributor agreement][1], +it’s the easiest way for you to give us permission to use your contributions. + +## Pull Requests and tests + +We need to verify that the code functionality and quality is not degraded +by additions before merging any changes to snapd's codebase. For each PR +we run checks in three different groups: static, unit and spread. + +Static test 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. Regarding [spread](https://github.com/snapcore/spread), +we use it to verify the integrity of the product exercising it as a whole, +both from an end user standpoint (eg. all kind of interactions with the +snap tool from the command line) and from a more systemic approach (testing +upgrades, for instance). + +We do not set as a requirement the addition of spread and unit tests for a PR +to be merged, but encourage the contributors to add them so that the expected +behaviour is explained and verified through the tests and the review process +can be made on the solid base of a working system after the addition of the +changes. If any tests need to be added for a PR to be merged it will be denoted +during the review process. + +[1]: http://www.ubuntu.com/legal/contributors 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..1f59fa0a --- /dev/null +++ b/HACKING.md @@ -0,0 +1,300 @@ +# 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. + +## Development + +### Supported Go versions + +From snapd 2.38, snapd supports Go 1.9 and onwards. For earlier snapd +releases, snapd supports Go 1.6. + +### Setting up a GOPATH + +When working with the source of Go programs, you should define a path within +your home directory (or other workspace) which will be your `GOPATH`. `GOPATH` +is similar to Java's `CLASSPATH` or Python's `~/.local`. `GOPATH` is documented +[online](http://golang.org/pkg/go/build/) and inside the go tool itself + + go help gopath + +Various conventions exist for naming the location of your `GOPATH`, but it +should exist, and be writable by you. For example + + export GOPATH=${HOME}/work + mkdir $GOPATH + +will define and create `$HOME/work` as your local `GOPATH`. The `go` tool +itself will create three subdirectories inside your `GOPATH` when required; +`src`, `pkg` and `bin`, which hold the source of Go programs, compiled packages +and compiled binaries, respectively. + +Setting `GOPATH` correctly is critical when developing Go programs. Set and +export it as part of your login script. + +Add `$GOPATH/bin` to your `PATH`, so you can run the go programs you install: + + PATH="$PATH:$GOPATH/bin" + +(note `$GOPATH` can actually point to multiple locations, like `$PATH`, so if +your `$GOPATH` is more complex than a single entry you'll need to adjust the +above). + +### Getting the snapd sources + +The easiest way to get the source for `snapd` is to use the `go get` command. + + go get -d -v github.com/snapcore/snapd/... + +This command will checkout the source of `snapd` and inspect it for any unmet +Go package dependencies, downloading those as well. `go get` will also build +and install `snapd` and its dependencies. To also build and install `snapd` +itself into `$GOPATH/bin`, omit the `-d` flag. More details on the `go get` +flags are available using + + go help get + +At this point you will have the git local repository of the `snapd` source at +`$GOPATH/src/github.com/snapcore/snapd`. The source for any +dependent packages will also be available inside `$GOPATH`. + +### Dependencies handling + +Go dependencies are handled via `govendor`. Get it via: + + go get -u github.com/kardianos/govendor + +After a fresh checkout, move to the snapd source directory: + + cd $GOPATH/src/github.com/snapcore/snapd + +And then, run: + + govendor sync + +You can use the script `get-deps.sh` to run the two previous steps. + +If a dependency need updating + + govendor fetch github.com/path/of/dependency + +Other dependencies are handled via distribution packages and you should ensure +that dependencies for your distribution are installed. For example, on Ubuntu, +run: + + sudo apt-get build-dep ./ + +### Building + +To build, once the sources are available and `GOPATH` is set, you can just run + + go build -o /tmp/snap github.com/snapcore/snapd/cmd/snap + +to get the `snap` binary in /tmp (or without -o to get it in the current +working directory). Alternatively: + + go install github.com/snapcore/snapd/cmd/snap/... + +to have it available in `$GOPATH/bin` + +Similarly, to build the `snapd` REST API daemon, you can run + + go build -o /tmp/snapd github.com/snapcore/snapd/cmd/snapd + +### Contributing + +Contributions are always welcome! Please make sure that you sign the +Canonical contributor license agreement at +http://www.ubuntu.com/legal/contributors + +Snapd can be found on GitHub, so in order to fork the source and contribute, +go to https://github.com/snapcore/snapd. Check out [GitHub's help +pages](https://help.github.com/) to find out how to set up your local branch, +commit changes and create pull requests. + +We value good tests, so when you fix a bug or add a new feature we highly +encourage you to create a test in `$source_test.go`. See also the section +about Testing. + +### Testing + +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. + +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 -check.v for less verbose output). + +There is more to read about the testing framework on the [website](https://labix.org/gocheck) + +### Running spread tests + +To run the spread tests locally via QEMU, you need the latest version of +[spread](https://github.com/snapcore/spread). 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://niemeyer.s3.amazonaws.com/spread-amd64.tar.gz | tar -xz -C $GOPATH/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) (or a later +development release like Ubuntu 19.04 (Disco Dingo)), run the following to +build a 64-bit Ubuntu 16.04 LTS (Xenial Xerus) VM to run the spread tests on: + + $ autopkgtest-buildvm-ubuntu-cloud -r xenial + $ mv autopkgtest-xenial-amd64.img ubuntu-16.04-64.img + +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-xenial-amd64-cloud.img ubuntu-16.04-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](spread.zygoon.pl). The images will need to be extracted +with `gunzip` and placed into `~/.spread/qemu` as above. + +#### Running spread with QEMU + +Finally, you can run the spread tests for Ubuntu 16.04 LTS 64-bit with: + + $ spread -v qemu:ubuntu-16.04-64 + +To run for a different system, replace `ubuntu-16.04-64` with a different system +name. + +For quick reuse you can use: + + $ spread -reuse qemu:ubuntu-16.04-64 + +It will print how to reuse the systems. Make sure to use +`export REUSE_PROJECT=1` in your environment too. + + +### Testing snapd + +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 `SNAP_DEBUG_HTTP`. +It is a bitfield: dump requests: 1, dump responses: 2, dump bodies: 4. + +(make hack: 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 rollback to the original when finish testing) + +### Running nested tests + +Nested tests are used to validate features which cannot be tested on regular tests. + +The nested test suites work different 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 spread tool. 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 folloing lines show the relation between host and nested systemd (same applies for 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 building the images + . QEMU is used for the virtualization (with kvm 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 by using ubuntu-image snap + core20: this is similar to core suite but tests on it are focused on UC20 + manual: tests on this suite create a non generic image with spedific conditions + +The nested suites use some environment variables to configure the suite and the tests inside it, the most important ones are the described bellow: + + NESTED_WORK_DIR: It is 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 (just supported on UC20) + NESTED_BUILD_SNAPD_FROM_CURRENT: Build and use either core or snapd snapd from current branch + NESTED_CUSTOM_IMAGE_URL: Download and use an custom image from this url + + +# 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/snap-confine` handy, it installs the locally built +version on your system and reloads the apparmor profile. + +Note, 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). + +## Submitting patches + +Please run `(cd cmd; make fmt)` before sending your patches for the "C" part of +the source code. 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..ab6e2c27 --- /dev/null +++ b/README.md @@ -0,0 +1,75 @@ +[![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/snappy/+filebug) on our [Launchpad issue +tracker](https://bugs.launchpad.net/snappy/) +- 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://webchat.freenode.net/?channels=snappy) IRC channel on +[freenode](https://freenode.net/). + +For news and updates, follow us on [Twitter](https://twitter.com/snapcraftio) +and on [Facebook](https://www.facebook.com/snapcraftio). + +## Project status + +| Service | Status | +|-----|:---| +| [Travis](https://travis-ci.org/) | ![Build Status][travis-image] | +| [GoReport](https://goreportcard.com/) | [![Go Report Card][goreportcard-image]][goreportcard-url] | +| [Codecov](https://codecov.io/) | [![codecov][codecov-image]][codecov-url] | + +[travis-image]: https://travis-ci.org/snapcore/snapd.svg?branch=master +[travis-url]: https://travis-ci.org/snapcore/snapd + +[goreportcard-image]: https://goreportcard.com/badge/github.com/snapcore/snapd +[goreportcard-url]: https://goreportcard.com/report/github.com/snapcore/snapd + +[coveralls-image]: https://coveralls.io/repos/snapcore/snapd/badge.svg?branch=master&service=github +[coveralls-url]: https://coveralls.io/github/snapcore/snapd?branch=master + +[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.go b/advisor/backend.go new file mode 100644 index 00000000..931c708f --- /dev/null +++ b/advisor/backend.go @@ -0,0 +1,300 @@ +// -*- 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 ( + "encoding/json" + "os" + "path/filepath" + "time" + + "github.com/snapcore/bolt" + + "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 *bolt.DB + tx *bolt.Tx + cmdBucket *bolt.Bucket + pkgBucket *bolt.Bucket +} + +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 +} + +// 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 = bolt.Open(t.fn, 0644, &bolt.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 := bolt.Open(dirs.SnapCommandsDB, 0644, &bolt.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 { + *bolt.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 := bolt.Open(dirs.SnapCommandsDB, 0644, &bolt.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/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/asserts/account.go b/asserts/account.go new file mode 100644 index 00000000..abf5d999 --- /dev/null +++ b/asserts/account.go @@ -0,0 +1,117 @@ +// -*- 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" or "verified", and +// for forward compatibility any value != "unproven" can be considered +// at least "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 +} + +// sanity +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..17ee949f --- /dev/null +++ b/asserts/account_key.go @@ -0,0 +1,288 @@ +// -*- 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" + "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 + since time.Time + until time.Time + pubKey PublicKey +} + +// 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() +} + +// isKeyValidAt returns whether the account key is valid at 'when' time. +func (ak *AccountKey) isKeyValidAt(when time.Time) bool { + valid := when.After(ak.since) || when.Equal(ak.since) + if valid && !ak.until.IsZero() { + valid = when.Before(ak.until) + } + return valid +} + +// publicKey returns the underlying public key of the account key. +func (ak *AccountKey) publicKey() PublicKey { + return ak.pubKey +} + +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 IsNotFound(err) { + 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 && !IsNotFound(err) { + 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 +} + +// sanity +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 + } + } + + since, err := checkRFC3339Date(assert.headers, "since") + if err != nil { + return nil, err + } + + until, err := checkRFC3339DateWithDefault(assert.headers, "until", 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") + } + + pubk, err := checkPublicKey(&assert, "public-key-sha3-384") + if err != nil { + return nil, err + } + + // ignore extra headers for future compatibility + return &AccountKey{ + assertionBase: assert, + since: since, + until: until, + pubKey: pubk, + }, 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 + since time.Time + until time.Time + 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 IsNotFound(err) { + 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 +} + +// sanity +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 + } + + since, err := checkRFC3339Date(assert.headers, "since") + if err != nil { + return nil, err + } + + until, err := checkRFC3339DateWithDefault(assert.headers, "until", 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") + } + + pubk, err := checkPublicKey(&assert, "public-key-sha3-384") + if err != nil { + return nil, err + } + + // ignore extra headers for future compatibility + return &AccountKeyRequest{ + assertionBase: assert, + since: since, + until: until, + pubKey: pubk, + }, nil +} diff --git a/asserts/account_key_test.go b/asserts/account_key_test.go new file mode 100644 index 00000000..d1fd153b --- /dev/null +++ b/asserts/account_key_test.go @@ -0,0 +1,809 @@ +// -*- 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 ( + "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) +} + +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) 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`) +} diff --git a/asserts/account_test.go b/asserts/account_test.go new file mode 100644 index 00000000..e5f3ddd4 --- /dev/null +++ b/asserts/account_test.go @@ -0,0 +1,195 @@ +// -*- 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" + + "github.com/snapcore/snapd/asserts" + . "gopkg.in/check.v1" +) + +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 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/asserts.go b/asserts/asserts.go new file mode 100644 index 00000000..cc6d2459 --- /dev/null +++ b/asserts/asserts.go @@ -0,0 +1,1102 @@ +// -*- 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 ( + "bufio" + "bytes" + "crypto" + "fmt" + "io" + "sort" + "strconv" + "strings" + "unicode/utf8" +) + +type typeFlags int + +const ( + noAuthority typeFlags = 1 << iota + sequenceForming +) + +// 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 + + assembler func(assert assertionBase) (Assertion, error) + flags typeFlags +} + +// MaxSupportedFormat returns the maximum supported format iteration for the type. +func (at *AssertionType) MaxSupportedFormat() int { + return maxSupportedFormat[at.Name] +} + +// SequencingForming 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 +} + +// Understood assertion types. +var ( + AccountType = &AssertionType{"account", []string{"account-id"}, assembleAccount, 0} + AccountKeyType = &AssertionType{"account-key", []string{"public-key-sha3-384"}, assembleAccountKey, 0} + RepairType = &AssertionType{"repair", []string{"brand-id", "repair-id"}, assembleRepair, sequenceForming} + ModelType = &AssertionType{"model", []string{"series", "brand-id", "model"}, assembleModel, 0} + SerialType = &AssertionType{"serial", []string{"brand-id", "model", "serial"}, assembleSerial, 0} + BaseDeclarationType = &AssertionType{"base-declaration", []string{"series"}, assembleBaseDeclaration, 0} + SnapDeclarationType = &AssertionType{"snap-declaration", []string{"series", "snap-id"}, assembleSnapDeclaration, 0} + SnapBuildType = &AssertionType{"snap-build", []string{"snap-sha3-384"}, assembleSnapBuild, 0} + SnapRevisionType = &AssertionType{"snap-revision", []string{"snap-sha3-384"}, assembleSnapRevision, 0} + SnapDeveloperType = &AssertionType{"snap-developer", []string{"snap-id", "publisher-id"}, assembleSnapDeveloper, 0} + SystemUserType = &AssertionType{"system-user", []string{"brand-id", "email"}, assembleSystemUser, 0} + ValidationType = &AssertionType{"validation", []string{"series", "snap-id", "approved-snap-id", "approved-snap-revision"}, assembleValidation, 0} + ValidationSetType = &AssertionType{"validation-set", []string{"series", "account-id", "name", "sequence"}, assembleValidationSet, sequenceForming} + StoreType = &AssertionType{"store", []string{"store"}, assembleStore, 0} + +// ... +) + +// 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"}, assembleDeviceSessionRequest, noAuthority} + SerialRequestType = &AssertionType{"serial-request", nil, assembleSerialRequest, noAuthority} + AccountKeyRequestType = &AssertionType{"account-key-request", []string{"public-key-sha3-384"}, 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, + // 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 + maxSupportedFormat[SnapDeclarationType.Name] = 4 + + // 1: support to limit to device serials + maxSupportedFormat[SystemUserType.Name] = 1 +} + +func MockMaxSupportedFormat(assertType *AssertionType, maxFormat int) (restore func()) { + prev := maxSupportedFormat[assertType.Name] + maxSupportedFormat[assertType.Name] = maxFormat + return func() { + maxSupportedFormat[assertType.Name] = prev + } +} + +var formatAnalyzer = map[*AssertionType]func(headers map[string]interface{}, body []byte) (formatnum int, err error){ + 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 +// has the wrong length. +func HeadersFromPrimaryKey(assertType *AssertionType, primaryKey []string) (headers map[string]string, err error) { + if len(primaryKey) != len(assertType.PrimaryKey) { + return nil, fmt.Errorf("primary key has wrong length for %q assertion", assertType.Name) + } + headers = make(map[string]string, len(assertType.PrimaryKey)) + for i, name := range assertType.PrimaryKey { + keyVal := primaryKey[i] + if keyVal == "" { + return nil, fmt.Errorf("primary key %q header cannot be empty", name) + } + headers[name] = keyVal + } + 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. +func PrimaryKeyFromHeaders(assertType *AssertionType, headers map[string]string) (primaryKey []string, err error) { + return keysFromHeaders(assertType.PrimaryKey, headers) +} + +func keysFromHeaders(keys []string, headers map[string]string) (keyValues []string, err error) { + keyValues = make([]string, len(keys)) + for i, k := range keys { + keyVal := headers[k] + if keyVal == "" { + return nil, fmt.Errorf("must provide primary key: %v", k) + } + keyValues[i] = keyVal + } + return keyValues, nil +} + +// 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) + if n != len(ref.PrimaryKey) { + pkStr = "???" + } else if n > 0 { + pkStr = ref.PrimaryKey[n-1] + if n > 1 { + sfx := []string{pkStr + ";"} + for i, k := range ref.Type.PrimaryKey[:n-1] { + sfx = append(sfx, fmt.Sprintf("%s:%s", k, ref.PrimaryKey[i])) + } + 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(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) +} + +// 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 that signed 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 signer id of 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()} +} + +// sanity check +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) +} + +// 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("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&noAuthority == 0 { + if _, err := checkNotEmptyString(headers, "authority-id"); err != nil { + return nil, fmt.Errorf("assertion: %v", err) + } + } else { + _, ok := headers["authority-id"] + if ok { + return nil, fmt.Errorf("%q assertion cannot have authority-id set", assertType.Name) + } + } + + formatnum, err := checkFormat(headers) + if err != nil { + return nil, fmt.Errorf("assertion: %v", err) + } + + for _, primKey := range assertType.PrimaryKey { + 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 + + 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") + } + + 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 := checkNotEmptyString(finalHeaders, "authority-id"); err != nil { + return nil, err + } + } else { + _, ok := finalHeaders["authority-id"] + if ok { + return nil, fmt.Errorf("%q assertion cannot have authority-id set", assertType.Name) + } + } + + 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 { + if _, err := checkPrimaryKey(finalHeaders, primKey); err != nil { + return nil, err + } + 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..1dc16e48 --- /dev/null +++ b/asserts/asserts_test.go @@ -0,0 +1,975 @@ +// -*- 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_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", + "base-declaration", + "device-session-request", + "model", + "repair", + "serial", + "serial-request", + "snap-build", + "snap-declaration", + "snap-developer", + "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) { + snapDeclMaxFormat := asserts.SnapDeclarationType.MaxSupportedFormat() + systemUserMaxFormat := asserts.SystemUserType.MaxSupportedFormat() + // sanity + c.Check(snapDeclMaxFormat >= 4, Equals, true) + c.Check(systemUserMaxFormat >= 1, Equals, true) + c.Check(asserts.MaxSupportedFormats(1), DeepEquals, map[string]int{ + "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) 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) 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 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", "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) +} + +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) TestSignFormatSanityEmptyBody(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) TestSignFormatSanityNonEmptyBody(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) TestSignFormatSanitySupportMultilineHeaderValues(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) 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", + "base-declaration", + "store", + "snap-declaration", + "snap-build", + "snap-revision", + "snap-developer", + "model", + "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, nil, 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) +} diff --git a/asserts/assertstest/assertstest.go b/asserts/assertstest/assertstest.go new file mode 100644 index 00000000..fd6f5e49 --- /dev/null +++ b/asserts/assertstest/assertstest.go @@ -0,0 +1,610 @@ +// -*- 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" + "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 asserts.IsNotFound(err) { + 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..9e938e08 --- /dev/null +++ b/asserts/batch.go @@ -0,0 +1,229 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016-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 asserts + +import ( + "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) +} + +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) +} + +// commitTo does a best effort of adding all the batch assertions to +// the target database. +func (b *Batch) commitTo(db *Database) error { + if err := b.prereqSort(db); err != nil { + return err + } + + // TODO: trigger w. caller a global sanity 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) + } + } + 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 IsNotFound(err) { + // 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 IsNotFound(err) { + 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..e784614a --- /dev/null +++ b/asserts/batch_test.go @@ -0,0 +1,475 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016-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 asserts_test + +import ( + "bytes" + "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) 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(asserts.IsNotFound(err), 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(asserts.IsNotFound(err), 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(asserts.IsNotFound(err), 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/crypto.go b/asserts/crypto.go new file mode 100644 index 00000000..d9397be4 --- /dev/null +++ b/asserts/crypto.go @@ -0,0 +1,398 @@ +// -*- 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" + _ "crypto/sha256" // be explicit about supporting SHA256 + _ "crypto/sha512" // be explicit about needing SHA512 + "encoding/base64" + "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 + pgpFingerprint string + bitLen int + doSign func(content []byte) ([]byte, error) +} + +func newExtPGPPrivateKey(exportedPubKeyStream io.Reader, from string, sign func(content []byte) ([]byte, 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, + pgpFingerprint: fmt.Sprintf("%X", pubKey.Fingerprint), + bitLen: rsaPubKey.N.BitLen(), + doSign: sign, + }, nil +} + +func (expk *extPGPPrivateKey) fingerprint() string { + return expk.pgpFingerprint +} + +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) + } + + out, err := expk.doSign(content) + if err != nil { + return nil, err + } + + badSig := fmt.Sprintf("bad %s produced signature: ", expk.from) + + 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) + } + + if sig.Hash != crypto.SHA512 { + return nil, fmt.Errorf(badSig + "expected SHA512 digest") + } + + err = expk.pubKey.verify(content, sig) + if err != nil { + return nil, fmt.Errorf(badSig+"it does not verify: %v", err) + } + + return sig, nil +} diff --git a/asserts/database.go b/asserts/database.go new file mode 100644 index 00000000..18fc8a71 --- /dev/null +++ b/asserts/database.go @@ -0,0 +1,756 @@ +// -*- 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 implements snappy assertions and a database +// abstraction for managing and holding them. +package asserts + +import ( + "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}) +} + +// IsNotFound returns whether err is an assertion not found error. +func IsNotFound(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. If none is 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} +} + +// 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. + Get(keyID string) (PrivateKey, 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 fmt.Sprintf("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. + // 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. 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. 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) + // 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, checkTime 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 +} + +// 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 sanity 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 !IsNotFound(err) { + 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 +} + +// 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() + now := time.Now() + + 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 IsNotFound(err) { + 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, now) + 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 && !IsNotFound(err) { + 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 !IsNotFound(err) { + 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 !IsNotFound(err) { + 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 !IsNotFound(err) { + 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 !IsNotFound(err) { + 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. +// 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. 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. +// 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) + 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 !IsNotFound(err) { + 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, checkTime 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.isKeyValidAt(checkTime) { + return fmt.Errorf("assertion is signed with expired public key %q from %q", assert.SignKeyID(), assert.AuthorityID()) + } + return nil +} + +// CheckSignature checks that the signature is valid. +func CheckSignature(assert Assertion, signingKey *AccountKey, roDB RODatabase, checkTime time.Time) error { + var pubKey PublicKey + if signingKey != nil { + pubKey = signingKey.publicKey() + } 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, checkTime 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.isKeyValidAt(checkTime) { + until := "" + if !signingKey.Until().IsZero() { + until = fmt.Sprintf(" until %q", signingKey.Until()) + } + return fmt.Errorf("%s assertion timestamp outside of signing key validity (key valid since %q%s)", assert.Type().Name, signingKey.Since(), until) + } + } + return nil +} + +// XXX: keeping these in this form until we know better + +// 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, checkTime 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..6abb0178 --- /dev/null +++ b/asserts/database_test.go @@ -0,0 +1,1400 @@ +// -*- 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_test + +import ( + "bytes" + "crypto" + "encoding/base64" + "errors" + "io/ioutil" + "os" + "path/filepath" + "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" +) + +func Test(t *testing.T) { TestingT(t) } + +var _ = Suite(&openSuite{}) +var _ = Suite(&revisionErrorSuite{}) + +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 white box? ok at least until we have more functionality + privKey, err := ioutil.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") +} + +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) { + trustedKey := testPrivKey0 + + cfg := &asserts.DatabaseConfig{ + Backstore: chks.bs, + Trusted: []asserts.Assertion{asserts.ExpiredAccountKeyForTest("canonical", trustedKey.PublicKey())}, + } + db, err := asserts.OpenDatabase(cfg) + c.Assert(err, IsNil) + + err = db.Check(chks.a) + c.Assert(err, ErrorMatches, `assertion is signed with expired public key "[[:alnum:]_-]+" from "canonical"`) +} + +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`) +} + +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(asserts.IsNotFound(err1), Equals, true) + c.Check(err1.Error(), Equals, "snap-declaration (snap-id; series:16) not found") + + err2 := &asserts.NotFoundError{ + Type: asserts.SnapRevisionType, + } + c.Check(asserts.IsNotFound(err1), 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(asserts.IsNotFound(err), 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(asserts.IsNotFound(err), 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`) +} + +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(asserts.IsNotFound(err), 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, + }) + +} + +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..fdd08090 --- /dev/null +++ b/asserts/export_test.go @@ -0,0 +1,294 @@ +// -*- 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 ( + "io" + "time" + + "github.com/snapcore/snapd/asserts/internal" +) + +// expose test-only things here + +var 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, 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(), + }, + }, + since: time.Time{}, + until: time.Time{}.UTC().AddDate(validYears, 0, 0), + pubKey: openPGPPubKey, + } +} + +func BootstrapAccountKeyForTest(authorityID string, pubKey PublicKey) *AccountKey { + return makeAccountKeyForTest(authorityID, pubKey, 9999) +} + +func ExpiredAccountKeyForTest(authorityID string, pubKey PublicKey) *AccountKey { + return makeAccountKeyForTest(authorityID, pubKey, 1) +} + +// define dummy 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"}, 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"}, 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"}, 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"}, assembleTestOnlyRev, 0} + +// TestOnlySeq is a test-only assertion that is sequence-forming. +type TestOnlySeq struct { + assertionBase + seq int +} + +func (seq *TestOnlyRev) 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"}, 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, 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"}, 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 +} + +// AccountKeyIsKeyValidAt exposes isKeyValidAt on AccountKey for tests +func AccountKeyIsKeyValidAt(ak *AccountKey, when time.Time) bool { + return ak.isKeyValidAt(when) +} + +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 + } +} + +// 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) +} + +// 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)) +} diff --git a/asserts/fetcher.go b/asserts/fetcher.go new file mode 100644 index 00000000..0e353e35 --- /dev/null +++ b/asserts/fetcher.go @@ -0,0 +1,121 @@ +// -*- 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" +) + +type fetchProgress int + +const ( + fetchNotSeen fetchProgress = iota + fetchRetrieved + fetchSaved +) + +// 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) + 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), + } +} + +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 !IsNotFound(err) { + return err + } + u := ref.Unique() + switch f.fetched[u] { + case fetchSaved: + return nil // nothing to do + case fetchRetrieved: + return fmt.Errorf("circular assertions are not expected: %s", ref) + } + if a == nil { + retrieved, err := f.retrieve(ref) + if err != nil { + return err + } + a = retrieved + } + f.fetched[u] = fetchRetrieved + for _, preref := range a.Prerequisites() { + 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[u] = fetchSaved + return nil +} + +// 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) +} + +// 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..36531e75 --- /dev/null +++ b/asserts/fetcher_test.go @@ -0,0 +1,167 @@ +// -*- 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 ( + "crypto" + "fmt" + "time" + + "golang.org/x/crypto/sha3" + + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/asserts" + "github.com/snapcore/snapd/asserts/assertstest" +) + +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) 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") +} 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..1fe4ca2c --- /dev/null +++ b/asserts/findwildcard_test.go @@ -0,0 +1,282 @@ +// -*- 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" + "io/ioutil" + "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 = ioutil.WriteFile(filepath.Join(top, "acc-id1", "abcd", "active"), nil, os.ModePerm) + c.Assert(err, check.IsNil) + err = ioutil.WriteFile(filepath.Join(top, "acc-id1", "abcd", "active.1"), nil, os.ModePerm) + c.Assert(err, check.IsNil) + err = ioutil.WriteFile(filepath.Join(top, "acc-id1", "e5cd", "active"), nil, os.ModePerm) + c.Assert(err, check.IsNil) + err = ioutil.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 = ioutil.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 = ioutil.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 = ioutil.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..64498f98 --- /dev/null +++ b/asserts/fsbackstore.go @@ -0,0 +1,275 @@ +// -*- 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" + "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 +} + +func diskPrimaryPathComps(primaryPath []string, active string) []string { + n := len(primaryPath) + comps := make([]string, n+1) + // safety against '/' etc + for i, comp := range primaryPath { + comps[i] = url.QueryEscape(comp) + } + comps[n] = 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(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(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() + + 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) Search(assertType *AssertionType, headers map[string]string, foundCb func(Assertion), maxFormat int) error { + fsbs.mu.RLock() + defer fsbs.mu.RUnlock() + + n := len(assertType.PrimaryKey) + diskPattern := make([]string, n+1) + for i, k := range assertType.PrimaryKey { + keyVal := headers[k] + if keyVal == "" { + diskPattern[i] = "*" + } else { + diskPattern[i] = url.QueryEscape(keyVal) + } + } + diskPattern[n] = "active*" + + candCb := func(a Assertion) { + if searchMatch(a, headers) { + foundCb(a) + } + } + return fsbs.search(assertType, diskPattern, candCb, 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..e8fa0791 --- /dev/null +++ b/asserts/fsbackstore_test.go @@ -0,0 +1,372 @@ +// -*- 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 asserts_test + +import ( + "os" + "path/filepath" + "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, + }) +} diff --git a/asserts/fsentryutils.go b/asserts/fsentryutils.go new file mode 100644 index 00000000..ca057d8c --- /dev/null +++ b/asserts/fsentryutils.go @@ -0,0 +1,70 @@ +// -*- 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" + "io/ioutil" + "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 ioutil.ReadFile(fpath) +} diff --git a/asserts/fskeypairmgr.go b/asserts/fskeypairmgr.go new file mode 100644 index 00000000..5a58ae17 --- /dev/null +++ b/asserts/fskeypairmgr.go @@ -0,0 +1,92 @@ +// -*- 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 = errors.New("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 +} diff --git a/asserts/fskeypairmgr_test.go b/asserts/fskeypairmgr_test.go new file mode 100644 index 00000000..422ccdde --- /dev/null +++ b/asserts/fskeypairmgr_test.go @@ -0,0 +1,65 @@ +// -*- 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) +} diff --git a/asserts/gpgkeypairmgr.go b/asserts/gpgkeypairmgr.go new file mode 100644 index 00000000..480cb709 --- /dev/null +++ b/asserts/gpgkeypairmgr.go @@ -0,0 +1,353 @@ +// -*- 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 ( + "bytes" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/snapcore/snapd/osutil" +) + +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 +} + +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"} + 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) ([]byte, 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.fingerprint() + 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] + } + } + // sanity 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") +} + +func (gkm *GPGKeypairManager) Get(keyID string) (PrivateKey, error) { + stop := errors.New("stop marker") + var hit PrivateKey + match := func(privk PrivateKey, fpr string, uid string) error { + if privk.PublicKey().ID() == keyID { + hit = privk + return stop + } + return nil + } + err := gkm.Walk(match) + if err == stop { + return hit, nil + } + if err != nil { + return nil, err + } + return nil, fmt.Errorf("cannot find key %q in GPG keyring", keyID) +} + +func (gkm *GPGKeypairManager) sign(fingerprint string, content []byte) ([]byte, 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) + } + return out, nil +} + +type gpgKeypairInfo struct { + privKey PrivateKey + fingerprint string +} + +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, fmt.Errorf("cannot find key named %q in GPG keyring", name) +} + +// 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()) +} + +// Delete removes the named key pair from GnuPG's storage. +func (gkm *GPGKeypairManager) Delete(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 +} diff --git a/asserts/gpgkeypairmgr_test.go b/asserts/gpgkeypairmgr_test.go new file mode 100644 index 00000000..30cbe346 --- /dev/null +++ b/asserts/gpgkeypairmgr_test.go @@ -0,0 +1,331 @@ +// -*- 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 ( + "bytes" + "crypto" + "crypto/rand" + "crypto/rsa" + "fmt" + "os" + "time" + + . "gopkg.in/check.v1" + + "golang.org/x/crypto/openpgp/packet" + + "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 "ffffffffffffffff" in GPG keyring`) + 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) + } +} diff --git a/asserts/header_checks.go b/asserts/header_checks.go new file mode 100644 index 00000000..df7c9da8 --- /dev/null +++ b/asserts/header_checks.go @@ -0,0 +1,312 @@ +// -*- 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 ( + "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") + } + // sanity check against known canonical + sanity := typeRegistry[assertType.Name] + switch sanity { + 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 checkRFC3339DateWithDefault(headers map[string]interface{}, name string, defl time.Time) (time.Time, error) { + return checkRFC3339DateWithDefaultWhat(headers, name, "header", defl) +} + +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) { + valueStr, err := checkNotEmptyString(headers, name) + 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 header is out of range: %v", name, valueStr) + } + return 0, fmt.Errorf("%q header is not an unsigned integer: %v", name, valueStr) + } + if prefixZeros(valueStr) { + return 0, fmt.Errorf("%q header has invalid prefix zeros: %s", name, valueStr) + } + return value, nil +} + +func checkDigest(headers map[string]interface{}, name string, h crypto.Hash) ([]byte, error) { + digestStr, err := checkNotEmptyString(headers, name) + if err != nil { + return nil, err + } + b, err := base64.RawURLEncoding.DecodeString(digestStr) + if err != nil { + return nil, fmt.Errorf("%q header cannot be decoded: %v", name, err) + } + if len(b) != h.Size() { + return nil, fmt.Errorf("%q header does not have the expected bit length: %d", name, 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) { + value, ok := headers[name] + if !ok { + return false, nil + } + s, ok := value.(string) + if !ok || (s != "true" && s != "false") { + return false, fmt.Errorf("%q header must be 'true' or 'false'", name) + } + return s == "true", nil +} + +func checkMap(headers map[string]interface{}, name string) (map[string]interface{}, error) { + value, ok := headers[name] + if !ok { + return nil, nil + } + m, ok := value.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("%q header must be a map", name) + } + return m, nil +} diff --git a/asserts/headers.go b/asserts/headers.go new file mode 100644 index 00000000..b7c42eb1 --- /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 sanity checking of header names + headerNameSanity = 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 !headerNameSanity.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 !headerNameSanity.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.IndexRune(x, '\n') != -1 { + // 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..688be433 --- /dev/null +++ b/asserts/ifacedecls.go @@ -0,0 +1,1447 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * 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 . + * + */ + +package asserts + +import ( + "errors" + "fmt" + "reflect" + "regexp" + "strconv" + "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 $SLOT()/$PLUG()/$MISSING + dollarAttrConstraintsFeature = "dollar-attr-constraints" + // feature label for on-store/on-brand/on-model + deviceScopeConstraintsFeature = "device-scope-constraints" + // feature label for plug-names/slot-names constraints + nameConstraintsFeature = "name-constraints" +) + +type attrMatcher interface { + match(apath string, v interface{}, ctx AttrMatchContext) error + + feature(flabel string) bool +} + +func chain(path, k string) string { + if path == "" { + return k + } + return fmt.Sprintf("%s.%s", path, k) +} + +type compileContext struct { + dotted string + hadMap bool + wasAlt bool +} + +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, + } +} + +func (cc compileContext) alt(alt int) compileContext { + return compileContext{ + dotted: fmt.Sprintf("%s/alt#%d/", cc.dotted, alt+1), + hadMap: cc.hadMap, + wasAlt: true, + } +} + +// 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: + return nil, fmt.Errorf("constraint %q must be a key-value map, regexp or a list of alternative constraints: %v", cc, 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 AttrMatchContext) 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("attribute %q has constraints but is unset", apath) + } + if err := matcher1.match(apath, v, ctx); err != nil { + return err + } + return nil +} + +func matchList(apath string, matcher attrMatcher, l []interface{}, ctx AttrMatchContext) 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 AttrMatchContext) 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("attribute %q must be a map", 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 AttrMatchContext) error { + if v != nil { + return fmt.Errorf("attribute %q is constrained to be missing but is set", apath) + } + return nil +} + +type evalAttrMatcher struct { + // first iteration supports just $(SLOT|PLUG)(arg) + op string + arg string +} + +var ( + validEvalAttrMatcher = regexp.MustCompile(`^\$(SLOT|PLUG)\((.+)\)$`) +) + +func compileEvalAttrMatcher(cc compileContext, s string) (attrMatcher, error) { + ops := validEvalAttrMatcher.FindStringSubmatch(s) + if len(ops) == 0 { + return nil, fmt.Errorf("cannot compile %q constraint %q: not a valid $SLOT()/$PLUG() constraint", cc, s) + } + 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 AttrMatchContext) error { + if ctx == nil { + return fmt.Errorf("attribute %q cannot be matched without context", apath) + } + var comp func(string) (interface{}, error) + switch matcher.op { + case "SLOT": + comp = ctx.SlotAttr + case "PLUG": + comp = ctx.PlugAttr + } + v1, err := comp(matcher.arg) + if err != nil { + return fmt.Errorf("attribute %q constraint $%s(%s) cannot be evaluated: %v", apath, matcher.op, matcher.arg, err) + } + if !reflect.DeepEqual(v, v1) { + return fmt.Errorf("attribute %q does not match $%s(%s): %v != %v", 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 AttrMatchContext) 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("attribute %q must be a scalar or list", apath) + } + if !matcher.Regexp.MatchString(s) { + return fmt.Errorf("attribute %q value %q does not match %v", 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 { + for _, alt := range matcher.alts { + if alt.feature(flabel) { + return true + } + } + return false +} + +func (matcher altAttrMatcher) match(apath string, v interface{}, ctx AttrMatchContext) error { + 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 attribute %q", apath) + } + return fmt.Errorf("no alternative%s matches: %v", apathDescr, firstErr) +} + +// 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) { + matcher, err := compileAttrMatcher(compileContext{}, 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 AttrMatchContext) 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, ctx AttrMatchContext) error { + return c.matcher.match("", attrer, ctx) +} + +// 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 +} + +// DeviceScopeConstraint specifies a constraints 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 +} + +func compileDeviceScopeConstraint(cMap map[string]interface{}, context string) (constr *DeviceScopeConstraint, err error) { + // 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 + } + + if len(deviceConstr) == 0 { + return nil, fmt.Errorf("internal error: misdetected device scope constraints in %s", context) + } + return &DeviceScopeConstraint{ + Store: deviceConstr["on-store"], + Brand: deviceConstr["on-brand"], + Model: deviceConstr["on-model"], + }, nil +} + +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) + } + if !detectDeviceScopeConstraint(cMap) { + defaultUsed++ + } else { + c, err := compileDeviceScopeConstraint(cMap, context.String()) + if err != nil { + return err + } + target.setDeviceScopeConstraint(c) + } + // 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 + + 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 + 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{} + err := baseCompileConstraints(context, cDef, plugInstCstrs, []string{"plug-names"}, []string{"plug-attributes"}, []string{"plug-snap-type"}) + 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 + + 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 + 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{} + err := baseCompileConstraints(context, cDef, slotInstCstrs, []string{"slot-names"}, []string{"slot-attributes"}, []string{"slot-snap-type"}) + 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 { + 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 "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{"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..6a61e102 --- /dev/null +++ b/asserts/ifacedecls_test.go @@ -0,0 +1,2339 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * 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 . + * + */ + +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) + } + + var ao attrerObject + ao = 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) TestSimpleAnchorsVsRegexpAlt(c *C) { + m, err := asserts.ParseHeaders([]byte(`attrs: + bar: BAR|BAZ`)) + c.Assert(err, IsNil) + + cstrs, err := asserts.CompileAttributeConstraints(m["attrs"].(map[string]interface{})) + c.Assert(err, IsNil) + + plug := attrerObject(map[string]interface{}{ + "bar": "BAR", + }) + err = cstrs.Check(plug, nil) + c.Check(err, IsNil) + + plug = attrerObject(map[string]interface{}{ + "bar": "BARR", + }) + err = cstrs.Check(plug, nil) + c.Check(err, ErrorMatches, `attribute "bar" value "BARR" does not match \^\(BAR|BAZ\)\$`) + + plug = attrerObject(map[string]interface{}{ + "bar": "BBAZ", + }) + err = cstrs.Check(plug, nil) + c.Check(err, ErrorMatches, `attribute "bar" value "BAZZ" does not match \^\(BAR|BAZ\)\$`) + + plug = attrerObject(map[string]interface{}{ + "bar": "BABAZ", + }) + err = cstrs.Check(plug, nil) + c.Check(err, ErrorMatches, `attribute "bar" value "BABAZ" does not match \^\(BAR|BAZ\)\$`) + + plug = attrerObject(map[string]interface{}{ + "bar": "BARAZ", + }) + err = cstrs.Check(plug, nil) + c.Check(err, ErrorMatches, `attribute "bar" value "BARAZ" does not match \^\(BAR|BAZ\)\$`) +} + +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) TestAlternative(c *C) { + m, err := asserts.ParseHeaders([]byte(`attrs: + - + foo: FOO + bar: BAR + - + foo: FOO + bar: BAZ`)) + c.Assert(err, IsNil) + + cstrs, err := asserts.CompileAttributeConstraints(m["attrs"].([]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, IsNil) + + plug = attrerObject(map[string]interface{}{ + "foo": "FOO", + "bar": "BARR", + "baz": "BAR", + }) + err = cstrs.Check(plug, nil) + c.Check(err, ErrorMatches, `no alternative matches: attribute "bar" value "BARR" does not match \^\(BAR\)\$`) +} + +func (s *attrConstraintsSuite) TestNestedAlternative(c *C) { + m, err := asserts.ParseHeaders([]byte(`attrs: + foo: FOO + bar: + bar1: BAR1 + bar2: + - BAR2 + - BAR22`)) + 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 +`), nil) + c.Check(err, IsNil) + + err = cstrs.Check(attrs(` +foo: FOO +bar: + bar1: BAR1 + bar2: BAR22 +`), nil) + c.Check(err, IsNil) + + err = cstrs.Check(attrs(` +foo: FOO +bar: + bar1: BAR1 + bar2: BAR3 +`), nil) + c.Check(err, ErrorMatches, `no alternative for attribute "bar\.bar2" matches: attribute "bar\.bar2" value "BAR3" does not match \^\(BAR2\)\$`) +} + +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) + + plug := attrerObject(map[string]interface{}{ + "foo": int64(1), + "bar": true, + }) + err = cstrs.Check(plug, 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(map[string]interface{}{ + "foo": []interface{}{"foo", "["}, + }) + c.Check(err, ErrorMatches, `cannot compile "foo/alt#2/" constraint "\[": error parsing regexp:.*`) + + _, err = asserts.CompileAttributeConstraints(map[string]interface{}{ + "foo": []interface{}{"foo", []interface{}{"bar", "baz"}}, + }) + c.Check(err, ErrorMatches, `cannot nest alternative constraints directly at "foo/alt#2/"`) + + _, 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))) + + } +} + +func (s *attrConstraintsSuite) TestMatchingListsSimple(c *C) { + m, err := asserts.ParseHeaders([]byte(`attrs: + foo: /foo/.*`)) + c.Assert(err, IsNil) + + cstrs, err := asserts.CompileAttributeConstraints(m["attrs"].(map[string]interface{})) + c.Assert(err, IsNil) + + err = cstrs.Check(attrs(` +foo: ["/foo/x", "/foo/y"] +`), nil) + c.Check(err, IsNil) + + err = cstrs.Check(attrs(` +foo: ["/foo/x", "/foo"] +`), nil) + c.Check(err, ErrorMatches, `attribute "foo\.1" value "/foo" does not match \^\(/foo/\.\*\)\$`) +} + +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) + + err = cstrs.Check(attrs(` +bar: baz +`), nil) + c.Check(err, IsNil) + + err = cstrs.Check(attrs(` +foo: ["x"] +`), nil) + c.Check(err, ErrorMatches, `attribute "foo" is constrained to be missing but is set`) +} + +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) TestMatchingListsMap(c *C) { + m, err := asserts.ParseHeaders([]byte(`attrs: + foo: + p: /foo/.*`)) + c.Assert(err, IsNil) + + cstrs, err := asserts.CompileAttributeConstraints(m["attrs"].(map[string]interface{})) + c.Assert(err, IsNil) + + err = cstrs.Check(attrs(` +foo: [{p: "/foo/x"}, {p: "/foo/y"}] +`), nil) + c.Check(err, IsNil) + + err = cstrs.Check(attrs(` +foo: [{p: "zzz"}, {p: "/foo/y"}] +`), nil) + c.Check(err, ErrorMatches, `attribute "foo\.0\.p" value "zzz" does not match \^\(/foo/\.\*\)\$`) +} + +func (s *attrConstraintsSuite) TestAlwaysMatchAttributeConstraints(c *C) { + c.Check(asserts.AlwaysMatchAttributes.Check(nil, nil), IsNil) +} + +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"}, + }, + }) + c.Assert(err, IsNil) + + c.Assert(rule.AllowInstallation, HasLen, 1) + cstrs := rule.AllowInstallation[0] + c.Check(cstrs.PlugSnapTypes, DeepEquals, []string{"core", "kernel", "gadget", "app"}) +} + +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"}, + }, + }) + c.Assert(err, IsNil) + + c.Assert(rule.AllowInstallation, HasLen, 1) + cstrs := rule.AllowInstallation[0] + c.Check(cstrs.SlotSnapTypes, DeepEquals, []string{"core", "kernel", "gadget", "app"}) +} + +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{}{ + "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.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: + 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, 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, 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, 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, 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) { + 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) { + 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/internal/grouping.go b/asserts/internal/grouping.go new file mode 100644 index 00000000..06ba7998 --- /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..6d88e62d --- /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) + + // sanity + 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..85f1e677 --- /dev/null +++ b/asserts/membackstore.go @@ -0,0 +1,290 @@ +// -*- 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 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) + } + return +} + +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() + + internalKey := make([]string, 1+len(assertType.PrimaryKey)) + internalKey[0] = assertType.Name + copy(internalKey[1:], key) + + 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..ceced28d --- /dev/null +++ b/asserts/membackstore_test.go @@ -0,0 +1,539 @@ +// -*- 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 asserts_test + +import ( + . "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{}) + + 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{}) + + 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, + }) + + 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, + }) +} diff --git a/asserts/memkeypairmgr.go b/asserts/memkeypairmgr.go new file mode 100644 index 00000000..68293a25 --- /dev/null +++ b/asserts/memkeypairmgr.go @@ -0,0 +1,59 @@ +// -*- 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 +} diff --git a/asserts/memkeypairmgr_test.go b/asserts/memkeypairmgr_test.go new file mode 100644 index 00000000..a99018ff --- /dev/null +++ b/asserts/memkeypairmgr_test.go @@ -0,0 +1,73 @@ +// -*- 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") + + 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") +} diff --git a/asserts/model.go b/asserts/model.go new file mode 100644 index 00000000..826b4206 --- /dev/null +++ b/asserts/model.go @@ -0,0 +1,857 @@ +// -*- 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 asserts + +import ( + "fmt" + "regexp" + "strings" + "time" + + "github.com/snapcore/snapd/snap/channel" + "github.com/snapcore/snapd/snap/naming" + "github.com/snapcore/snapd/strutil" +) + +// 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 +} + +// 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) (*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, grade) + 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 + } + + essential := false + switch { + case modelSnap.SnapType == "snapd": + // TODO: allow to be explicit only in grade: dangerous? + essential = true + 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": + essential = true + 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": + essential = true + 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: + essential = true + if modelSnap.SnapType != "base" { + return nil, fmt.Errorf(`boot base %q must specify type "base", not %q`, base, modelSnap.SnapType) + } + modelSnaps.base = modelSnap + } + + if essential { + if len(modelSnap.Modes) != 0 || modelSnap.Presence != "" { + return nil, fmt.Errorf("essential snaps are always available, cannot specify modes or presence for snap %q", modelSnap.Name) + } + modelSnap.Modes = essentialSnapModes + } + + if len(modelSnap.Modes) == 0 { + modelSnap.Modes = defaultModes + } + if modelSnap.Presence == "" { + modelSnap.Presence = "required" + } + + if !essential { + 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 checkModelSnap(snap map[string]interface{}, grade ModelGrade) (*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, "|")) + } + + modes, err := checkStringListInMap(snap, "modes", fmt.Sprintf("%q %s", "modes", what), validSnapMode) + 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) + } + + 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) + } + + return &ModelSnap{ + Name: name, + SnapID: snapID, + SnapType: typ, + Modes: modes, // can be empty + DefaultChannel: defaultChannel, + Presence: presence, // can be empty + }, 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, +} + +// 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 +} + +// 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 + + serialAuthority []string + sysUserAuthority []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 +} + +// 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:] +} + +// 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 +} + +// 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 +} + +// sanity +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 checkOptionalSerialAuthority(headers map[string]interface{}, brandID string) ([]string, error) { + ids := []string{brandID} + const name = "serial-authority" + if _, ok := headers[name]; !ok { + return ids, nil + } + if lst, err := checkStringListMatches(headers, name, validAccountID); err == nil { + if !strutil.ListContains(lst, brandID) { + lst = append(ids, lst...) + } + return lst, nil + } + return nil, fmt.Errorf("%q header must be a list of account ids", name) +} + +func checkOptionalSystemUserAuthority(headers map[string]interface{}, brandID string) ([]string, error) { + ids := []string{brandID} + const name = "system-user-authority" + v, ok := headers[name] + if !ok { + return ids, nil + } + switch x := v.(type) { + case string: + if 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 + } + } + return nil, fmt.Errorf("%q header must be '*' or a list of account ids", name) +} + +var ( + modelMandatory = []string{"architecture", "gadget", "kernel"} + extendedCoreMandatory = []string{"architecture", "base"} + extendedSnapsConflicting = []string{"gadget", "kernel", "required-snaps"} + classicModelOptional = []string{"architecture", "gadget"} +) + +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 { + if classic { + return nil, fmt.Errorf("cannot use extended snaps header for a classic model (yet)") + } + + 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 { + if _, ok := assert.headers["kernel"]; ok { + return nil, fmt.Errorf("cannot specify a kernel with a classic model") + } + if _, ok := assert.headers["base"]; ok { + return nil, fmt.Errorf("cannot specify a base with a classic model") + } + } + + checker := checkNotEmptyString + toCheck := modelMandatory + if extended { + toCheck = extendedCoreMandatory + } 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) + if err != nil { + return nil, err + } + if modSnaps.gadget == nil { + return nil, fmt.Errorf(`one "snaps" header entry must specify the model gadget`) + } + if modSnaps.kernel == nil { + return nil, fmt.Errorf(`one "snaps" header entry must specify the model kernel`) + } + + 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 + } + + timestamp, err := checkRFC3339Date(assert.headers, "timestamp") + if err != nil { + return nil, err + } + + allSnaps, requiredWithEssentialSnaps, numEssentialSnaps := modSnaps.list() + + // 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, + serialAuthority: serialAuthority, + sysUserAuthority: sysUserAuthority, + timestamp: timestamp, + }, nil +} diff --git a/asserts/model_test.go b/asserts/model_test.go new file mode 100644 index 00000000..dca64227 --- /dev/null +++ b/asserts/model_test.go @@ -0,0 +1,942 @@ +// -*- 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 asserts_test + +import ( + "fmt" + "strings" + "time" + + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/asserts" + "github.com/snapcore/snapd/snap/naming" +) + +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" +) + +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 + + "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==" +) + +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"}) +} + +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) 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`}, + {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`}, + {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 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.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 classic model`}, + {"gadget: brand-gadget\n", "base: some-base\n", `cannot specify a base with a 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.Architecture(), Equals, "") + c.Check(model.GadgetSnap(), IsNil) + c.Check(model.Gadget(), Equals, "") + c.Check(model.GadgetTrack(), Equals, "") +} + +func (mods *modelSuite) TestCore20DecodeOK(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) + 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"}, + 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", + }, + }) + // 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"}) +} + +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) TestCore20DecodeInvalid(c *C) { + encoded := strings.Replace(core20ModelExample, "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`}, + {"OTHER", "classic: true\n", `cannot use extended snaps header for a classic model \(yet\)`}, + {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 modes or presence for snap "brand-gadget"`}, + {"type: gadget\n", "type: gadget\n modes:\n - run\n", `essential snaps are always available, cannot specify modes or presence for snap "brand-gadget"`}, + {"type: kernel\n", "type: kernel\n presence: required\n", `essential snaps are always available, cannot specify modes or 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 modes or presence for snap "core20"`}, + {"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`}, + {"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`}, + } + 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) + } +} diff --git a/asserts/pool.go b/asserts/pool.go new file mode 100644 index 00000000..e9810130 --- /dev/null +++ b/asserts/pool.go @@ -0,0 +1,775 @@ +// -*- 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 ( + "errors" + "fmt" + + "github.com/snapcore/snapd/asserts/internal" +) + +// A Grouping identifies opaquely a grouping of assertions. +// Pool uses it to label the interesection 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. 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]*unresolvedRec + prerequisites map[string]*unresolvedRec + + 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]*unresolvedRec), + prerequisites: make(map[string]*unresolvedRec), + 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 +} + +// 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) 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 + } +} + +// 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 !IsNotFound(err) { + 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 !IsNotFound(err) { + return false, err + } + return false, nil +} + +func (p *Pool) curRevision(ref *Ref) (int, error) { + a, err := ref.Resolve(p.groundDB.Find) + if err != nil && !IsNotFound(err) { + 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 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) +} + +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 *unresolvedRec + if u = p.unresolved[uniq]; u == nil { + u = &unresolvedRec{ + at: unresolved, + } + p.unresolved[uniq] = u + } + u.merge(unresolved, gnum, p.groupings) + return nil +} + +// 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, error) { + if p.curPhase == poolPhaseAdd { + p.unresolvedBookkeeping() + } else { + p.curPhase = poolPhaseAdd + } + r := make(map[Grouping][]*AtRevision) + for _, u := range p.unresolved { + if u.at.Revision == RevisionNotKnown { + rev, err := p.curRevision(&u.at.Ref) + if err != nil { + return nil, err + } + if rev != RevisionNotKnown { + u.at.Revision = rev + } + } + u.exportTo(r, p.groupings) + } + return r, 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 { + u.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()}, + } + if err := p.addPrerequisite(keyRef, g); err != nil { + return err + } + return nil +} + +func (p *Pool) resolveWith(unresolved map[string]*unresolvedRec, uniq string, u *unresolvedRec, a Assertion, extrag *internal.Grouping) (ok bool, err error) { + if a.Revision() > u.at.Revision { + if extrag == nil { + extrag = &u.grouping + } else { + p.groupings.Iter(&u.grouping, 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) { + uniq := a.Ref().Unique() + var u *unresolvedRec + var extrag *internal.Grouping + var unresolved map[string]*unresolvedRec + 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 + u = &unresolvedRec{ + at: a.At(), + } + u.at.Revision = RevisionNotKnown + } + + if u.serializedLabel != 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 + for uniq, u := range p.unresolved { + e := u.err + if e == nil { + if u.at.Revision == RevisionNotKnown { + e = ErrUnresolved + } else { + // unchanged + p.unchanged[uniq] = true + } + } + if e != nil { + p.setErr(&u.grouping, e) + } + delete(p.unresolved, uniq) + } + + // prerequisites will become the new unresolved but drop them + // if all their groups are in error + for uniq, prereq := range p.prerequisites { + 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.err == nil { + u.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 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 +} + +// 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 IsNotFound(err) { + // 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 +} diff --git a/asserts/pool_test.go b/asserts/pool_test.go new file mode 100644 index 00000000..5363c34b --- /dev/null +++ b/asserts/pool_test.go @@ -0,0 +1,1019 @@ +// -*- 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 ( + "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 + + 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) + + 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, err := pool.ToResolve() + c.Assert(err, IsNil) + c.Check(toResolve, DeepEquals, map[asserts.Grouping][]*asserts.AtRevision{ + asserts.MakePoolGrouping(0): {at1}, + }) +} + +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, err := pool.ToResolve() + c.Assert(err, IsNil) + c.Check(toResolve, 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, err := pool.ToResolve() + c.Assert(err, IsNil) + c.Check(toResolve, DeepEquals, map[asserts.Grouping][]*asserts.AtRevision{ + asserts.MakePoolGrouping(0, 1): {storeKeyAt}, + }) +} + +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, err := pool.ToResolve() + c.Assert(err, IsNil) + c.Check(toResolve, DeepEquals, map[asserts.Grouping][]*asserts.AtRevision{ + asserts.MakePoolGrouping(0): {storeKeyAt}, + }) +} + +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, err := pool.ToResolve() + c.Assert(err, IsNil) + c.Check(toResolve, DeepEquals, map[asserts.Grouping][]*asserts.AtRevision{ + asserts.MakePoolGrouping(0): {at1111}, + }) + + ok, err := pool.Add(s.rev1_1111, asserts.MakePoolGrouping(0)) + c.Assert(err, IsNil) + c.Assert(ok, Equals, true) + + toResolve, 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(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, err := pool.ToResolve() + c.Assert(err, IsNil) + c.Check(toResolve, DeepEquals, map[asserts.Grouping][]*asserts.AtRevision{ + asserts.MakePoolGrouping(0): {at1111}, + }) + + ok, err := pool.Add(s.rev1_1111, asserts.MakePoolGrouping(0)) + c.Assert(err, IsNil) + c.Assert(ok, Equals, true) + + toResolve, 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}, + }) + + 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, err = pool.ToResolve() + c.Assert(err, IsNil) + c.Check(toResolve, 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, err := pool.ToResolve() + c.Assert(err, IsNil) + c.Check(toResolve, DeepEquals, map[asserts.Grouping][]*asserts.AtRevision{ + asserts.MakePoolGrouping(0): {at1111}, + }) + + 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, 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(pool.Err("for_one"), IsNil) + + ok, err = pool.Add(s.dev1Acct, asserts.MakePoolGrouping(0)) + c.Assert(err, IsNil) + c.Assert(ok, Equals, true) + + toResolve, err = pool.ToResolve() + c.Assert(err, IsNil) + c.Check(toResolve, 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, err := pool.ToResolve() + c.Assert(err, IsNil) + c.Check(toResolve, DeepEquals, map[asserts.Grouping][]*asserts.AtRevision{ + asserts.MakePoolGrouping(0): {atOne}, + }) + + 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, 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(pool.Err("for_one"), IsNil) + + ok, err = pool.Add(s.dev1Acct, asserts.MakePoolGrouping(0)) + c.Assert(err, IsNil) + c.Assert(ok, Equals, true) + + toResolve, err = pool.ToResolve() + c.Assert(err, IsNil) + c.Check(toResolve, 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) 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, err := pool.ToResolve() + c.Assert(err, IsNil) + c.Check(toResolve, DeepEquals, map[asserts.Grouping][]*asserts.AtRevision{ + asserts.MakePoolGrouping(0): {atOne}, + }) + + 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, 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(pool.Err("for_one"), IsNil) + + ok, err = pool.Add(s.dev1Acct, asserts.MakePoolGrouping(0)) + c.Assert(err, IsNil) + c.Assert(ok, Equals, true) + + toResolve, err = pool.ToResolve() + c.Assert(err, IsNil) + c.Check(toResolve, 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, err := pool.ToResolve() + c.Assert(err, IsNil) + c.Check(toResolve, DeepEquals, map[asserts.Grouping][]*asserts.AtRevision{ + asserts.MakePoolGrouping(0): {at1}, + }) + + toResolve, err = pool.ToResolve() + c.Assert(err, IsNil) + c.Check(toResolve, 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, 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}, + }) +} + +func (s *poolSuite) TestUnknownGroup(c *C) { + pool := asserts.NewPool(s.db, 64) + + _, err := pool.Singleton("suggestion") + c.Assert(err, IsNil) + // sanity + 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, 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()}, + }) + + // re-adding of current revisions, is not what we expect + // but needs not to produce unneeded 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, err = pool.ToResolve() + c.Assert(err, IsNil) + c.Assert(toResolve, 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, 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()}, + }) + + ok, err := pool.Add(s.decl1_1, asserts.MakePoolGrouping(0)) + c.Assert(err, IsNil) + c.Assert(ok, Equals, true) + + toResolve, err = pool.ToResolve() + c.Assert(err, IsNil) + c.Check(toResolve, 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, 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(pool.Err("for_one"), IsNil) + c.Check(pool.Err("for_two"), IsNil) +} + +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) + + toResolve, 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}, + }) + + err = pool.AddError(errBoom, storeKey.Ref()) + c.Assert(err, IsNil) + + ok, err := pool.Add(s.rev1_1111, asserts.MakePoolGrouping(1)) + c.Assert(err, IsNil) + c.Assert(ok, Equals, true) + + toResolve, err = pool.ToResolve() + c.Assert(err, IsNil) + c.Check(toResolve, HasLen, 0) + + c.Check(pool.Err("store_key"), Equals, errBoom) + c.Check(pool.Err("for_one"), 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, 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}, + }) + + 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, err = pool.ToResolve() + c.Assert(err, IsNil) + c.Check(toResolve, 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, 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}, + }) + + // 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, err = pool.ToResolve() + c.Assert(err, IsNil) + c.Assert(toResolve, 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, err = pool.ToResolve() + c.Assert(err, IsNil) + c.Assert(toResolve, 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, err := pool.ToResolve() + c.Assert(err, IsNil) + c.Check(toResolve, DeepEquals, map[asserts.Grouping][]*asserts.AtRevision{ + asserts.MakePoolGrouping(0): {storeKey.At()}, + }) +} + +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, err := pool.ToResolve() + c.Assert(err, IsNil) + c.Check(toResolve, DeepEquals, map[asserts.Grouping][]*asserts.AtRevision{ + asserts.MakePoolGrouping(0): {storeKey.At()}, + }) +} + +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, 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}, + }) + + ok, err := pool.Add(s.rev1_1111, asserts.MakePoolGrouping(1)) + c.Assert(err, IsNil) + c.Assert(ok, Equals, true) + + toResolve, 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(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, err := pool.ToResolve() + c.Assert(err, IsNil) + c.Check(toResolve, DeepEquals, map[asserts.Grouping][]*asserts.AtRevision{ + asserts.MakePoolGrouping(0): {atOne}, + }) + + ok, err := pool.Add(s.decl1, asserts.MakePoolGrouping(0)) + c.Assert(err, IsNil) + c.Assert(ok, Equals, true) + + toResolve, 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}, + }) + + // failed to get prereqs + c.Check(pool.AddGroupingError(errBoom, asserts.MakePoolGrouping(0)), IsNil) + + err = pool.AddUnresolved(atOne, "other") + c.Assert(err, IsNil) + + toResolve, err = pool.ToResolve() + c.Assert(err, IsNil) + c.Check(toResolve, 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) + + toResolve, err := pool.ToResolve() + c.Assert(err, IsNil) + c.Check(toResolve, HasLen, 2) + + err = pool.AddError(errBoom, storeKey.Ref()) + c.Assert(err, IsNil) + + toResolve, err = pool.ToResolve() + c.Assert(err, IsNil) + c.Check(toResolve, HasLen, 0) + + c.Check(pool.Errors(), DeepEquals, map[string]error{ + "store_key": errBoom, + "for_one": 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, 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()}, + }) + + ok, err := pool.Add(s.decl1_1, asserts.MakePoolGrouping(0)) + c.Assert(err, IsNil) + c.Assert(ok, Equals, true) + + toResolve, err = pool.ToResolve() + c.Assert(err, IsNil) + c.Check(toResolve, 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, 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()}, + }) +} 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..978f9d0e --- /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 +} + +// sanity +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..47470eaf --- /dev/null +++ b/asserts/repair_test.go @@ -0,0 +1,367 @@ +// -*- 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" + "io/ioutil" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/snapcore/snapd/asserts" + . "gopkg.in/check.v1" +) + +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 = ioutil.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..8c06c77c --- /dev/null +++ b/asserts/serial_asserts.go @@ -0,0 +1,250 @@ +// -*- 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" + "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 && !IsNotFound(err) { + return err + } + if IsNotFound(err) || !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/sign.go b/asserts/signtool/sign.go new file mode 100644 index 00000000..a341278b --- /dev/null +++ b/asserts/signtool/sign.go @@ -0,0 +1,110 @@ +// -*- 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 offers tooling to sign assertions. +package signtool + +import ( + "encoding/json" + "fmt" + + "github.com/snapcore/snapd/asserts" +) + +// Options specifies the complete input for signing an assertion. +type Options struct { + // KeyID specifies the key id of the key to use + KeyID string + + // 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 + } + + // TODO: teach Sign to cross check keyID and authority-id + // against an 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..681e0f02 --- /dev/null +++ b/asserts/signtool/sign_test.go @@ -0,0 +1,262 @@ +// -*- 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" + "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 +} + +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() +} + +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) 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{} + }{ + {`cannot parse the assertion input as JSON:.*`, + []byte("\x00"), + nil, + }, + {`invalid assertion type: what`, + exampleJSON(map[string]interface{}{"type": "what"}), + nil, + }, + {`assertion type must be a string, not: \[\]`, + exampleJSON(map[string]interface{}{"type": emptyList}), + nil, + }, + {`missing assertion type header`, + exampleJSON(map[string]interface{}{"type": nil}), + nil, + }, + {"revision should be positive: -10", + exampleJSON(map[string]interface{}{"revision": "-10"}), + nil, + }, + {`"authority-id" header is mandatory`, + exampleJSON(map[string]interface{}{"authority-id": nil}), + nil, + }, + {`body if specified must be a string`, + exampleJSON(map[string]interface{}{"body": emptyList}), + nil, + }, + {`repeated assertion type does not match`, + exampleJSON(nil), + map[string]interface{}{"type": "foo"}, + }, + {`complementary header "kernel" clashes with assertion input`, + exampleJSON(nil), + map[string]interface{}{"kernel": "foo"}, + }, + } + + for _, t := range tests { + fresh := opts + + fresh.Statement = t.brokenStatement + fresh.Complement = t.complement + + _, 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..3bf8e039 --- /dev/null +++ b/asserts/snap_asserts.go @@ -0,0 +1,949 @@ +// -*- 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 ( + "bytes" + "crypto" + "fmt" + "time" + + _ "golang.org/x/crypto/sha3" // expected for digests + + "github.com/snapcore/snapd/osutil" + "github.com/snapcore/snapd/release" + "github.com/snapcore/snapd/snap/naming" +) + +// 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 + 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 +} + +// 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 IsNotFound(err) { + 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 +} + +// sanity +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 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 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 + } + + return &SnapDeclaration{ + assertionBase: assert, + refreshControl: refControl, + plugRules: plugRules, + slotRules: slotRules, + autoAliases: autoAliases, + aliases: aliases, + timestamp: timestamp, + }, nil +} + +// 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 +} + +// SnapSHA3_384 returns the SHA3-384 digest of the snap. +func (snaprev *SnapRevision) SnapSHA3_384() string { + return snaprev.HeaderString("snap-sha3-384") +} + +// 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 +} + +// Implement further consistency checks. +func (snaprev *SnapRevision) checkConsistency(db RODatabase, acck *AccountKey) error { + // TODO: expand this to consider other stores signing on their own + if !db.IsTrustedAccount(snaprev.AuthorityID()) { + 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 IsNotFound(err) { + 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 + } + _, 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 IsNotFound(err) { + 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 + } + return nil +} + +// sanity +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 = 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 + } + + return &SnapRevision{ + assertionBase: assert, + snapSize: snapSize, + snapRevision: snapRevision, + timestamp: timestamp, + }, 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 IsNotFound(err) { + 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 IsNotFound(err) { + 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 +} + +// sanity +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 +} + +// sanity +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 IsNotFound(err) { + 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 IsNotFound(err) { + 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 IsNotFound(err) { + 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 +} + +// sanity +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..9d4bac78 --- /dev/null +++ b/asserts/snap_asserts_test.go @@ -0,0 +1,1919 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * 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 . + * + */ + +package asserts_test + +import ( + "encoding/base64" + "io/ioutil" + "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", + }) +} + +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) 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) + } + } +} + +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 := ioutil.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 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) makeHeaders(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) 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) +} + +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-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 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 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) 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/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..630f3a71 --- /dev/null +++ b/asserts/snapasserts/snapasserts.go @@ -0,0 +1,158 @@ +// -*- 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 snapasserts offers helpers to handle snap related assertions and their checking for installation. +package snapasserts + +import ( + "fmt" + + "github.com/snapcore/snapd/asserts" + "github.com/snapcore/snapd/release" + "github.com/snapcore/snapd/snap" +) + +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) +} + +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 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. +func CrossCheck(instanceName, snapSHA3_384 string, snapSize uint64, si *snap.SideInfo, db Finder) error { + // get relevant assertions and do cross checks + a, err := db.Find(asserts.SnapRevisionType, map[string]string{ + "snap-sha3-384": snapSHA3_384, + }) + if err != nil { + return fmt.Errorf("internal error: cannot find pre-populated snap-revision assertion for %q: %s", instanceName, snapSHA3_384) + } + snapRev := a.(*asserts.SnapRevision) + + if snapRev.SnapSize() != snapSize { + return 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 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 err + } + + if snapDecl.SnapName() != snap.InstanceSnap(instanceName) { + return fmt.Errorf("cannot install %q, snap %q is undergoing a rename to %q", instanceName, snap.InstanceSnap(instanceName), snapDecl.SnapName()) + } + + 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. +func DeriveSideInfo(snapPath string, db Finder) (*snap.SideInfo, error) { + snapSHA3_384, snapSize, err := asserts.SnapFileSHA3_384(snapPath) + if err != nil { + return nil, err + } + + // get relevant assertions and reconstruct metadata + a, err := db.Find(asserts.SnapRevisionType, map[string]string{ + "snap-sha3-384": snapSHA3_384, + }) + if err != nil { + return nil, err + } + + 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 + } + + 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 using the given fetcher. +func FetchSnapAssertions(f asserts.Fetcher, snapSHA3_384 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}, + } + + 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..b0d64d04 --- /dev/null +++ b/asserts/snapasserts/snapasserts_test.go @@ -0,0 +1,334 @@ +// -*- 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 snapasserts_test + +import ( + "crypto" + "fmt" + "io/ioutil" + "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" +) + +func TestSnapasserts(t *testing.T) { TestingT(t) } + +type snapassertsSuite struct { + storeSigning *assertstest.StoreStack + dev1Acct *asserts.Account + + 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) + + 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) +} + +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 + err = snapasserts.CrossCheck("foo", digest, size, si, s.localDB) + c.Check(err, IsNil) + // and a snap instance name + err = snapasserts.CrossCheck("foo_instance", digest, size, si, 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, 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, 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), + }, 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), + }, 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), + }, 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), + }, 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, 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, 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, s.localDB) + c.Check(err, ErrorMatches, `cannot install snap "foo" with a revoked snap declaration`) + err = snapasserts.CrossCheck("foo_instance", digest, size, si, s.localDB) + c.Check(err, ErrorMatches, `cannot install snap "foo_instance" with a revoked snap declaration`) +} + +func (s *snapassertsSuite) TestDeriveSideInfoHappy(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), + "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 = ioutil.WriteFile(snapPath, fakeSnap(42), 0644) + c.Assert(err, IsNil) + + si, err := snapasserts.DeriveSideInfo(snapPath, 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 := ioutil.WriteFile(snapPath, fakeSnap(42), 0644) + c.Assert(err, IsNil) + + _, err = snapasserts.DeriveSideInfo(snapPath, s.localDB) + // cannot find signatures with metadata for snap + c.Assert(asserts.IsNotFound(err), 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 = ioutil.WriteFile(snapPath, fakeSnap(42), 0644) + c.Assert(err, IsNil) + + _, err = snapasserts.DeriveSideInfo(snapPath, 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 = ioutil.WriteFile(snapPath, fakeSnap(42), 0644) + c.Assert(err, IsNil) + + _, err = snapasserts.DeriveSideInfo(snapPath, s.localDB) + c.Check(err, ErrorMatches, fmt.Sprintf(`cannot install snap %q with a revoked snap declaration`, snapPath)) +} diff --git a/asserts/snapasserts/validation_sets.go b/asserts/snapasserts/validation_sets.go new file mode 100644 index 00000000..be9c58d0 --- /dev/null +++ b/asserts/snapasserts/validation_sets.go @@ -0,0 +1,452 @@ +// -*- 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" + "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() +} + +// ValidationSetsValidationError describes an error arising +// from validation of snaps against ValidationSets. +type ValidationSetsValidationError struct { + // MissingSnaps maps missing snap names to the validation sets requiring them. + MissingSnaps map[string][]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 referenced by above maps to actual + // validation sets. + Sets map[string]*asserts.ValidationSet +} + +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)) + } + } + + printDetails("missing required snaps", e.MissingSnaps, func(snapName string, validationSetKeys []string) string { + return fmt.Sprintf("%s (required by sets %s)", snapName, strings.Join(validationSetKeys, ",")) + }) + 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()) +} + +// 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 + return +} + +// 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) 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[string]bool) + wrongrev := make(map[string]map[snap.Revision]map[string]bool) + sets := make(map[string]*asserts.ValidationSet) + + for _, cstrs := range v.snaps { + for rev, revCstr := range cstrs.revisions { + for _, rc := range revCstr { + sn := installed.Lookup(rc) + isInstalled := sn != nil + + 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 + sets[rc.validationSetKey] = v.sets[rc.validationSetKey] + 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 + sets[rc.validationSetKey] = v.sets[rc.validationSetKey] + } + default: + // not installed but required + if missing[rc.Name] == nil { + missing[rc.Name] = make(map[string]bool) + } + missing[rc.Name][rc.validationSetKey] = true + sets[rc.validationSetKey] = v.sets[rc.validationSetKey] + } + } + } + } + + 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), + MissingSnaps: setsToLists(missing), + Sets: sets, + } + 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 +} diff --git a/asserts/snapasserts/validation_sets_test.go b/asserts/snapasserts/validation_sets_test.go new file mode 100644 index 00000000..74736f59 --- /dev/null +++ b/asserts/snapasserts/validation_sets_test.go @@ -0,0 +1,654 @@ +// -*- 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 ( + "fmt" + "sort" + + . "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" +) + +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) + 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) + + 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) + + 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)) + // 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][]string + expectedWrongRev map[string]map[snap.Revision][]string + }{ + { + // required snaps not installed + snaps: nil, + expectedMissing: map[string][]string{ + "snap-b": {"acme/fooname"}, + "snap-d": {"acme/barname"}, + }, + }, + { + // required snaps not installed + snaps: []*snapasserts.InstalledSnap{ + snapZ, + }, + expectedMissing: map[string][]string{ + "snap-b": {"acme/fooname"}, + "snap-d": {"acme/barname"}, + }, + }, + { + snaps: []*snapasserts.InstalledSnap{ + // covered by acme/fooname validation-set + snapB, + // covered by acme/barname validation-set. snap-e not installed but optional + snapDrev99}, + // 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}, + 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}, + expectedInvalid: map[string][]string{ + "snap-a": {"acme/booname", "acme/fooname"}, + }, + expectedMissing: map[string][]string{ + "snap-b": {"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}, + // 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}, + 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}, + 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}, + expectedMissing: map[string][]string{ + "snap-d": {"acme/barname"}, + }, + }, + { + snaps: []*snapasserts.InstalledSnap{ + // required snaps from acme/fooname are not installed. + // covered by acme/barname validation-set + snapDrev99, + snapE}, + expectedMissing: map[string][]string{ + "snap-b": {"acme/fooname"}, + }, + }, + { + snaps: []*snapasserts.InstalledSnap{ + // covered by acme/fooname validation-set, required missing. + snapC, + // covered by acme/barname validation-set, required missing. + snapE}, + expectedMissing: map[string][]string{ + "snap-b": {"acme/fooname"}, + "snap-d": {"acme/barname"}, + }, + }, + // local snaps + { + snaps: []*snapasserts.InstalledSnap{ + // covered by acme/fooname validation-set. + snapB, + // covered by acme/barname validation-set, local snap-d. + snapDlocal}, + // all fine + }, + { + snaps: []*snapasserts.InstalledSnap{ + // covered by acme/fooname validation-set, snap-a is invalid. + snapAlocal, + snapB, + // covered by acme/barname validation-set. + snapD}, + 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}, + 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) + } + } + } + + for i, tc := range tests { + err := valsets.CheckInstalledSnaps(tc.snaps) + 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(tc.expectedInvalid, DeepEquals, verr.InvalidSnaps, Commentf("#%d", i)) + c.Assert(tc.expectedMissing, DeepEquals, verr.MissingSnaps, Commentf("#%d", i)) + c.Assert(tc.expectedWrongRev, DeepEquals, verr.WrongRevisionSnaps, Commentf("#%d", i)) + checkSets(verr.InvalidSnaps, verr.Sets) + } +} + +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", + "revision": "5", + "presence": "required", + }, + }, + }).(*asserts.ValidationSet) + + valsets := snapasserts.NewValidationSets() + c.Assert(valsets.Add(vs1), IsNil) + c.Assert(valsets.Add(vs2), 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 by sets acme/barname,acme/fooname\\)", + }, + { + []*snapasserts.InstalledSnap{snapA}, + "validation sets assertions are not met:\n" + + "- missing required snaps:\n" + + " - snap-b \\(required by sets acme/barname,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, at revision 5 by sets acme/barname\\)", + }, + } + + for i, tc := range tests { + err := valsets.CheckInstalledSnaps(tc.snaps) + 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)}) +} diff --git a/asserts/store_asserts.go b/asserts/store_asserts.go new file mode 100644 index 00000000..74c559de --- /dev/null +++ b/asserts/store_asserts.go @@ -0,0 +1,162 @@ +// -*- 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" + "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 IsNotFound(err) { + 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..8bc37d1f --- /dev/null +++ b/asserts/store_asserts_test.go @@ -0,0 +1,235 @@ +// -*- 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" + + "github.com/snapcore/snapd/asserts" + "github.com/snapcore/snapd/asserts/assertstest" + . "gopkg.in/check.v1" +) + +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..c26a48a4 --- /dev/null +++ b/asserts/sysdb/staging.go @@ -0,0 +1,183 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- +// +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..2696477f --- /dev/null +++ b/asserts/sysdb/sysdb_test.go @@ -0,0 +1,216 @@ +// -*- 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/dirs" + + "github.com/snapcore/snapd/asserts" + "github.com/snapcore/snapd/asserts/assertstest" + "github.com/snapcore/snapd/asserts/sysdb" +) + +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..7b615645 --- /dev/null +++ b/asserts/sysdb/testkeys.go @@ -0,0 +1,30 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- +// +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..f7da21d3 --- /dev/null +++ b/asserts/system_user.go @@ -0,0 +1,313 @@ +// -*- 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" +) + +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 + + 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 +} + +// 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 +} + +// sanity +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 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") + } + + // "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, + 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 + } + + return formatnum, nil +} diff --git a/asserts/system_user_test.go b/asserts/system_user_test.go new file mode 100644 index 00000000..017173e5 --- /dev/null +++ b/asserts/system_user_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 asserts_test + +import ( + "fmt" + "strings" + "time" + + "github.com/snapcore/snapd/asserts" + . "gopkg.in/check.v1" +) + +var ( + _ = Suite(&systemUserSuite{}) +) + +type systemUserSuite struct { + until time.Time + untilLine string + since time.Time + sinceLine 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" + + "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.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) +} + +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) +} + +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`}, + } + + 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 sanity, 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) 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) +} diff --git a/asserts/systestkeys/trusted.go b/asserts/systestkeys/trusted.go new file mode 100644 index 00000000..2df06bd3 --- /dev/null +++ b/asserts/systestkeys/trusted.go @@ -0,0 +1,263 @@ +// -*- 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----- +` + + 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== +` +) + +var ( + TestRootAccount asserts.Assertion + TestRootAccountKey asserts.Assertion + // here for convenience, does not need to be in the trusted set + TestStoreAccountKey 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)) + } + + TestRootAccount = acct + TestRootAccountKey = accKey + TestStoreAccountKey = storeAccKey + Trusted = []asserts.Assertion{TestRootAccount, TestRootAccountKey} +} diff --git a/asserts/validation_set.go b/asserts/validation_set.go new file mode 100644 index 00000000..ed4e6180 --- /dev/null +++ b/asserts/validation_set.go @@ -0,0 +1,266 @@ +// -*- 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 +} + +// 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..1bfca957 --- /dev/null +++ b/asserts/validation_set_test.go @@ -0,0 +1,187 @@ +// -*- 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)) + } +} diff --git a/boot/assets.go b/boot/assets.go new file mode 100644 index 00000000..40e9839e --- /dev/null +++ b/boot/assets.go @@ -0,0 +1,859 @@ +// -*- 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/logger" + "github.com/snapcore/snapd/osutil" + "github.com/snapcore/snapd/secboot" + "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 []string + trackedAssets bootAssetsMap + + recoveryBlName string + trustedRecoveryAssets []string + trackedRecoveryAssets bootAssetsMap + + dataEncryptionKey secboot.EncryptionKey + saveEncryptionKey secboot.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, affectedStruct *gadget.LaidOutStructure, root, relativeTarget string, data *gadget.ContentChange) (gadget.ContentChangeAction, error) { + if affectedStruct.Role != 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 + } + if len(o.trustedAssets) == 0 || !strutil.ListContains(o.trustedAssets, relativeTarget) { + // not one of the trusted assets + return gadget.ChangeApply, nil + } + ta, err := o.cache.Add(data.After, o.blName, filepath.Base(relativeTarget)) + 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 := range o.trustedRecoveryAssets { + ta, err := o.cache.Add(filepath.Join(recoveryRootDir, trustedAsset), o.recoveryBlName, filepath.Base(trustedAsset)) + 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 secboot.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 := sealedKeysMethod(dirs.GlobalRootDir) + switch { + case err == nil: + trackTrustedAssets = true + case err == 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 []string + bootManagedAssets []string + changedAssets []*trackedAsset + + seedBootloader bootloader.Bootloader + seedTrustedAssets []string + seedManagedAssets []string + seedChangedAssets []*trackedAsset + + modeenv *Modeenv +} + +func trustedAndManagedAssetsOfBootloader(bl bootloader.Bootloader) (trustedAssets, 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 []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, 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, affectedStruct *gadget.LaidOutStructure, root, relativeTarget string, data *gadget.ContentChange) (gadget.ContentChangeAction, error) { + var whichBootloader bootloader.Bootloader + var whichTrustedAssets []string + var whichManagedAssets []string + var err error + var isRecovery bool + + switch affectedStruct.Role { + case gadget.SystemBoot: + whichBootloader = o.bootBootloader + whichTrustedAssets = o.bootTrustedAssets + whichManagedAssets = o.bootManagedAssets + case gadget.SystemSeed: + 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 + } + + // maybe an asset that is trusted in the boot process? + if !strutil.ListContains(whichTrustedAssets, relativeTarget) { + // 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 + o.modeenv, err = ReadModeenv("") + if err != nil { + return gadget.ChangeAbort, fmt.Errorf("cannot load modeenv: %v", err) + } + } + switch op { + case gadget.ContentUpdate: + return o.observeUpdate(whichBootloader, isRecovery, root, relativeTarget, data) + case gadget.ContentRollback: + return o.observeRollback(whichBootloader, isRecovery, root, relativeTarget, data) + default: + // we only care about update and rollback actions + return gadget.ChangeApply, nil + } +} + +func (o *TrustedAssetsUpdateObserver) observeUpdate(bl bootloader.Bootloader, recovery bool, root, relativeTarget 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(), filepath.Base(relativeTarget)) + if err != nil { + return gadget.ChangeAbort, err + } + } + + ta, err := o.cache.Add(change.After, bl.Name(), filepath.Base(relativeTarget)) + 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) + } + (*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, data *gadget.ContentChange) (gadget.ContentChangeAction, error) { + trustedAssets := &o.modeenv.CurrentTrustedBootAssets + otherTrustedAssets := o.modeenv.CurrentTrustedRecoveryBootAssets + if recovery { + trustedAssets = &o.modeenv.CurrentTrustedRecoveryBootAssets + otherTrustedAssets = o.modeenv.CurrentTrustedBootAssets + } + + assetName := filepath.Base(relativeTarget) + hashList, ok := (*trustedAssets)[assetName] + if !ok || len(hashList) == 0 { + // asset not tracked in modeenv + return gadget.ChangeApply, nil + } + + // new assets are appended to the list + expectedOldHash := hashList[0] + // sanity 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", + assetName) + } + } 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, assetName, newHash) { + // asset revision is not used used elsewhere, we can remove it from the cache + if err := o.cache.Remove(bl.Name(), assetName, newHash); err != nil { + // XXX: should this be a log instead? + return gadget.ChangeAbort, fmt.Errorf("cannot remove unused boot asset %v:%v: %v", assetName, newHash, err) + } + } + + // update modeenv content + if !newlyAdded { + (*trustedAssets)[assetName] = hashList[:1] + } else { + delete(*trustedAssets, assetName) + } + + 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.model, o.modeenv, expectReseal); 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.model, o.modeenv, expectReseal); 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) + for _, trustedAsset := range trustedAssets { + assetName := filepath.Base(trustedAsset) + + // find the hash of the file on disk + assetHash, err := cache.fileHash(filepath.Join(root, trustedAsset)) + if err != nil && !os.IsNotExist(err) { + return nil, fmt.Errorf("cannot calculate the digest of existing trusted asset: %v", err) + } + if assetHash == "" { + // no trusted asset on disk, but we booted nonetheless, + // at least log something + logger.Noticef("system booted without %v bootloader trusted asset %q", whichBootloader, trustedAsset) + // given that asset names cannot be reused, clear the + // boot assets map for the current bootloader + 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) { + 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..7a98a6a5 --- /dev/null +++ b/boot/assets_test.go @@ -0,0 +1,2765 @@ +// -*- 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" + "io/ioutil" + "os" + "path/filepath" + "syscall" + + . "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/dirs" + "github.com/snapcore/snapd/gadget" + "github.com/snapcore/snapd/logger" + "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 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) +} + +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) + return obs, uc20Model +} + +func (s *assetsSuite) bootloaderWithTrustedAssets(c *C, trustedAssets []string) *bootloadertest.MockTrustedAssetsBootloader { + tab := bootloadertest.Mock("trusted", "").WithTrustedAssets() + bootloader.Force(tab) + tab.TrustedAssetsList = 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 := ioutil.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 = ioutil.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 = ioutil.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 := ioutil.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) + // sanity + 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(ioutil.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(c, []string{ + "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) +} + +var ( + mockRunBootStruct = &gadget.LaidOutStructure{ + VolumeStructure: &gadget.VolumeStructure{ + Role: gadget.SystemBoot, + }, + } + mockSeedStruct = &gadget.LaidOutStructure{ + VolumeStructure: &gadget.VolumeStructure{ + Role: gadget.SystemSeed, + }, + } +) + +func (s *assetsSuite) TestInstallObserverObserveSystemBootRealGrub(c *C) { + d := c.MkDir() + + // mock a bootloader that uses trusted assets + err := ioutil.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 = ioutil.WriteFile(filepath.Join(d, "foobar"), data, 0644) + c.Assert(err, IsNil) + + otherData := []byte("other foobar") + err = ioutil.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, mockRunBootStruct, 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, mockRunBootStruct, 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, mockRunBootStruct, 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, mockRunBootStruct, 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 + systemSeedStruct := &gadget.LaidOutStructure{ + VolumeStructure: &gadget.VolumeStructure{ + Role: gadget.SystemSeed, + }, + } + otherWriteChange := &gadget.ContentChange{ + After: filepath.Join(d, "other-foobar"), + } + res, err = obs.Observe(gadget.ContentWrite, systemSeedStruct, 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(c, []string{ + "asset", + "nested/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 = ioutil.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, mockRunBootStruct, boot.InitramfsUbuntuBootDir, + "asset", writeChange) + c.Assert(err, IsNil) + c.Check(res, Equals, gadget.ChangeApply) + // observe same asset again + res, err = obs.Observe(gadget.ContentWrite, mockRunBootStruct, boot.InitramfsUbuntuBootDir, + "asset", writeChange) + c.Assert(err, IsNil) + c.Check(res, Equals, gadget.ChangeApply) + // different one + res, err = obs.Observe(gadget.ContentWrite, mockRunBootStruct, 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, mockRunBootStruct, 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(c, []string{"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(c, []string{"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(ioutil.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, mockRunBootStruct, 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(secboot.EncryptionKey{1, 2, 3, 4}, secboot.EncryptionKey{5, 6, 7, 8}) + c.Check(obs.CurrentDataEncryptionKey(), DeepEquals, secboot.EncryptionKey{1, 2, 3, 4}) + c.Check(obs.CurrentSaveEncryptionKey(), DeepEquals, secboot.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(secboot.EncryptionKey{1, 2, 3, 4}, secboot.EncryptionKey{5, 6, 7, 8}) + c.Check(obs.CurrentDataEncryptionKey(), DeepEquals, secboot.EncryptionKey{1, 2, 3, 4}) + c.Check(obs.CurrentSaveEncryptionKey(), DeepEquals, secboot.EncryptionKey{5, 6, 7, 8}) +} + +func (s *assetsSuite) TestInstallObserverTrustedReuseNameErr(c *C) { + d := c.MkDir() + + tab := s.bootloaderWithTrustedAssets(c, []string{ + "asset", + "nested/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 = ioutil.WriteFile(filepath.Join(d, "foobar"), []byte("foobar"), 0644) + c.Assert(err, IsNil) + err = ioutil.WriteFile(filepath.Join(d, "other"), []byte("other"), 0644) + c.Assert(err, IsNil) + res, err := obs.Observe(gadget.ContentWrite, mockRunBootStruct, 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, mockRunBootStruct, 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(c, []string{ + "asset", + "nested/other-asset", + "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 = ioutil.WriteFile(filepath.Join(d, "asset"), data, 0644) + c.Assert(err, IsNil) + err = os.Mkdir(filepath.Join(d, "nested"), 0755) + c.Assert(err, IsNil) + err = ioutil.WriteFile(filepath.Join(d, "nested/other-asset"), data, 0644) + c.Assert(err, IsNil) + shim := []byte("shim") + shimHash := "dac0063e831d4b2e7a330426720512fc50fa315042f0bb30f9d1db73e4898dcb89119cac41fdfa62137c8931a50f9d7b" + err = ioutil.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(c, []string{ + "asset", + "nested/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 = ioutil.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 = ioutil.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) TestInstallObserverObserveExistingRecoveryButMissingErr(c *C) { + d := c.MkDir() + + tab := s.bootloaderWithTrustedAssets(c, []string{ + "asset", + }) + + uc20Model := boottest.MakeMockUC20Model() + useEncryption := true + obs, err := boot.TrustedAssetsInstallObserverForModel(uc20Model, d, useEncryption) + c.Assert(err, IsNil) + c.Assert(obs, NotNil) + c.Check(tab.TrustedAssetsCalls, Equals, 2) + + // trusted asset is missing + err = obs.ObserveExistingTrustedRecoveryAssets(d) + c.Assert(err, ErrorMatches, "cannot open asset file: .*/asset: no such file or directory") +} + +func (s *assetsSuite) TestUpdateObserverNew(c *C) { + tab := s.bootloaderWithTrustedAssets(c, 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.TrustedAssetsList = []string{"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.TrustedAssetsList = 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.TrustedAssetsList = []string{"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) TestUpdateObserverUpdateMockedWithReseal(c *C) { + // 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 := ioutil.WriteFile(filepath.Join(backups, "asset.backup"), before, 0644) + c.Assert(err, IsNil) + + data := []byte("foobar") + // SHA3-384 + dataHash := "0fa8abfbdaf924ad307b74dd2ed183b9a4a398891a2f6bac8fd2db7041b77f068580f9c6c66f699b496c2da1cbcc7ed8" + err = ioutil.WriteFile(filepath.Join(d, "foobar"), data, 0644) + c.Assert(err, IsNil) + shim := []byte("shim") + shimHash := "dac0063e831d4b2e7a330426720512fc50fa315042f0bb30f9d1db73e4898dcb89119cac41fdfa62137c8931a50f9d7b" + err = ioutil.WriteFile(filepath.Join(d, "shim"), shim, 0644) + c.Assert(err, IsNil) + + m := boot.Modeenv{ + Mode: "run", + CurrentTrustedBootAssets: boot.BootAssetsMap{ + "asset": {beforeHash}, + "shim": {"shim-hash"}, + }, + CurrentTrustedRecoveryBootAssets: boot.BootAssetsMap{ + "asset": {beforeHash}, + }, + } + err = m.WriteTo("") + c.Assert(err, IsNil) + + tab := s.bootloaderWithTrustedAssets(c, []string{ + "asset", + "nested/other-asset", + "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, mockRunBootStruct, 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, mockRunBootStruct, 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, mockSeedStruct, 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, mockSeedStruct, 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, mockSeedStruct, 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": {beforeHash, dataHash}, + "shim": {"shim-hash", shimHash}, + }) + c.Check(newM.CurrentTrustedRecoveryBootAssets, DeepEquals, boot.BootAssetsMap{ + "asset": {beforeHash, dataHash}, + "shim": {shimHash}, + "other-asset": {dataHash}, + }) + + // verify that managed assets are to be preserved + res, err = obs.Observe(gadget.ContentUpdate, mockSeedStruct, 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(c, []string{ + "asset", + "shim", + }) + tab.ManagedAssetsList = []string{ + "managed-asset", + "nested/managed-asset", + } + + data := []byte("foobar") + // SHA3-384 + dataHash := "0fa8abfbdaf924ad307b74dd2ed183b9a4a398891a2f6bac8fd2db7041b77f068580f9c6c66f699b496c2da1cbcc7ed8" + err := ioutil.WriteFile(filepath.Join(d, "foobar"), data, 0644) + c.Assert(err, IsNil) + shim := []byte("shim") + shimHash := "dac0063e831d4b2e7a330426720512fc50fa315042f0bb30f9d1db73e4898dcb89119cac41fdfa62137c8931a50f9d7b" + err = ioutil.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": {"asset-hash"}, + }, + CurrentTrustedRecoveryBootAssets: boot.BootAssetsMap{ + // shim with same hash is listed as trusted, but missing + // from cache + "shim": {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, mockRunBootStruct, 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, mockSeedStruct, 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, mockSeedStruct, 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": {"asset-hash", dataHash}, + }) + c.Check(newM.CurrentTrustedRecoveryBootAssets, DeepEquals, boot.BootAssetsMap{ + "asset": {dataHash}, + "shim": {shimHash}, + }) + + // verify that managed assets are to be preserved + res, err = obs.Observe(gadget.ContentUpdate, mockSeedStruct, 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, mockSeedStruct, 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(c, []string{ + "asset", + }) + + data := []byte("foobar") + // SHA3-384 + dataHash := "0fa8abfbdaf924ad307b74dd2ed183b9a4a398891a2f6bac8fd2db7041b77f068580f9c6c66f699b496c2da1cbcc7ed8" + err := ioutil.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, mockRunBootStruct, 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, mockSeedStruct, 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": {dataHash}, + }) + c.Check(newM.CurrentTrustedRecoveryBootAssets, DeepEquals, boot.BootAssetsMap{ + "asset": {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(c, []string{ + "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) + + // non system-boot or system-seed structure gets ignored + mockVolumeStruct := &gadget.LaidOutStructure{ + VolumeStructure: &gadget.VolumeStructure{ + Role: gadget.SystemData, + }, + } + + // observe the updates + res, err := obs.Observe(gadget.ContentUpdate, mockVolumeStruct, 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.TrustedAssetsList = []string{"asset"} + + obs, err = boot.TrustedAssetsUpdateObserverForModel(uc20Model, gadgetDir) + c.Assert(err, IsNil) + c.Assert(obs, NotNil) + c.Check(bl.TrustedAssetsCalls, Equals, 2) + + // no modeenv + res, err := obs.Observe(gadget.ContentUpdate, mockRunBootStruct, 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, mockSeedStruct, 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, mockRunBootStruct, 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, mockRunBootStruct, 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, mockSeedStruct, 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.TrustedAssetsList = []string{"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": {"one", "two"}, + }, + CurrentTrustedRecoveryBootAssets: boot.BootAssetsMap{ + "asset": {"one", "two"}, + }, + } + err := m.WriteTo("") + c.Assert(err, IsNil) + + // and the source file + err = ioutil.WriteFile(filepath.Join(d, "foobar"), nil, 0644) + c.Assert(err, IsNil) + + res, err := obs.Observe(gadget.ContentUpdate, mockRunBootStruct, 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, mockSeedStruct, 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 := ioutil.WriteFile(filepath.Join(backups, "asset.backup"), before, 0644) + c.Assert(err, IsNil) + + data := []byte("foobar") + // SHA3-384 + dataHash := "0fa8abfbdaf924ad307b74dd2ed183b9a4a398891a2f6bac8fd2db7041b77f068580f9c6c66f699b496c2da1cbcc7ed8" + err = ioutil.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": {dataHash}, + }, + CurrentTrustedRecoveryBootAssets: boot.BootAssetsMap{ + "asset": {dataHash}, + }, + } + err = m.WriteTo("") + c.Assert(err, IsNil) + + s.bootloaderWithTrustedAssets(c, []string{ + "asset", + }) + + // we get an observer for UC20 + obs, _ := s.uc20UpdateObserverEncryptedSystemMockedBootloader(c) + + res, err := obs.Observe(gadget.ContentUpdate, mockRunBootStruct, 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, mockSeedStruct, 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": {beforeHash, dataHash}, + }) + c.Check(newM.CurrentTrustedRecoveryBootAssets, DeepEquals, boot.BootAssetsMap{ + // same here + "asset": {beforeHash, dataHash}, + }) +} + +func (s *assetsSuite) TestUpdateObserverRollbackModeenvManipulationMocked(c *C) { + root := c.MkDir() + rootSeed := c.MkDir() + d := c.MkDir() + backups := c.MkDir() + + tab := s.bootloaderWithTrustedAssets(c, []string{ + "asset", + "nested/other-asset", + "shim", + }) + + data := []byte("foobar") + // SHA3-384 + dataHash := "0fa8abfbdaf924ad307b74dd2ed183b9a4a398891a2f6bac8fd2db7041b77f068580f9c6c66f699b496c2da1cbcc7ed8" + // file exists in both run and seed bootloader rootdirs + c.Assert(ioutil.WriteFile(filepath.Join(root, "asset"), data, 0644), IsNil) + c.Assert(ioutil.WriteFile(filepath.Join(rootSeed, "asset"), data, 0644), IsNil) + // and in the gadget + c.Assert(ioutil.WriteFile(filepath.Join(d, "asset"), data, 0644), IsNil) + // would be listed as Before + c.Assert(ioutil.WriteFile(filepath.Join(backups, "asset.backup"), data, 0644), IsNil) + + shim := []byte("shim") + shimHash := "dac0063e831d4b2e7a330426720512fc50fa315042f0bb30f9d1db73e4898dcb89119cac41fdfa62137c8931a50f9d7b" + // only exists in seed bootloader rootdir + c.Assert(ioutil.WriteFile(filepath.Join(rootSeed, "shim"), shim, 0644), IsNil) + // and in the gadget + c.Assert(ioutil.WriteFile(filepath.Join(d, "shim"), shim, 0644), IsNil) + // would be listed as Before + c.Assert(ioutil.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 := ioutil.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": {dataHash, "newhash"}, + }, + CurrentTrustedRecoveryBootAssets: boot.BootAssetsMap{ + // no new version added during update + "asset": {dataHash}, + // new version added during update + "shim": {shimHash, "newshimhash"}, + // completely new file + "other-asset": {"newotherhash"}, + }, + } + err := m.WriteTo("") + c.Assert(err, IsNil) + + res, err := obs.Observe(gadget.ContentRollback, mockRunBootStruct, 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, mockRunBootStruct, 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, mockSeedStruct, 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, mockSeedStruct, 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, mockSeedStruct, 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": {dataHash}, + }) + c.Check(newM.CurrentTrustedRecoveryBootAssets, DeepEquals, boot.BootAssetsMap{ + "asset": {dataHash}, + "shim": {shimHash}, + }) +} + +func (s *assetsSuite) TestUpdateObserverRollbackFileSanity(c *C) { + root := c.MkDir() + + tab := s.bootloaderWithTrustedAssets(c, []string{"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": {"newhash"}, + }, + CurrentTrustedRecoveryBootAssets: boot.BootAssetsMap{ + // same thing + "asset": {"newhash"}, + }, + } + err := m.WriteTo("") + c.Assert(err, IsNil) + // file does not exist on disk + res, err := obs.Observe(gadget.ContentRollback, mockRunBootStruct, root, "asset", + &gadget.ContentChange{}) + c.Assert(err, IsNil) + c.Check(res, Equals, gadget.ChangeApply) + // observe the recovery struct + res, err = obs.Observe(gadget.ContentRollback, mockSeedStruct, 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) + + // 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": {"newhash", "bogushash"}, + }, + CurrentTrustedRecoveryBootAssets: boot.BootAssetsMap{ + // same thing + "asset": {"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, mockRunBootStruct, 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, mockSeedStruct, 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 = ioutil.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, mockRunBootStruct, 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, mockSeedStruct, 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(ioutil.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 = ioutil.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": {"0d0c6522fcc813770f2bb9ca68ad3b4f0ccc6b4bfbd2e8497030079e6146f92177ad8f6f83d96ab61d7d42f5228a4389"}, + }, + CurrentTrustedRecoveryBootAssets: boot.BootAssetsMap{ + "grubx64.efi": {"6c3e6fc78ade5aadc5f9f0603a127346cc174436eb5e0188e108a376c3ba4d8951c460a8f51674e797c06951f74cb10d"}, + "bootx64.efi": {"c0437507ac094a7e9c699725cc0a4726cd10799af9eb79bbeaa136c2773163c80432295c2a04d3aa2ddd535ce8f1a12b"}, + }, + } + err := m.WriteTo("") + c.Assert(err, IsNil) + + // updates first + res, err := obs.Observe(gadget.ContentUpdate, mockRunBootStruct, 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, mockSeedStruct, 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, mockSeedStruct, 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, mockRunBootStruct, 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, mockSeedStruct, 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": { + // old hash + "0d0c6522fcc813770f2bb9ca68ad3b4f0ccc6b4bfbd2e8497030079e6146f92177ad8f6f83d96ab61d7d42f5228a4389", + // update + "f9554844308e89b565c1cdbcbdb9b09b8210dd2f1a11cb3b361de0a59f780ae3d4bd6941729a60e0f8ce15b2edef605d", + }, + }) + c.Check(newM.CurrentTrustedRecoveryBootAssets, DeepEquals, boot.BootAssetsMap{ + "grubx64.efi": { + // old hash + "6c3e6fc78ade5aadc5f9f0603a127346cc174436eb5e0188e108a376c3ba4d8951c460a8f51674e797c06951f74cb10d", + // update + "f9554844308e89b565c1cdbcbdb9b09b8210dd2f1a11cb3b361de0a59f780ae3d4bd6941729a60e0f8ce15b2edef605d", + }, + "bootx64.efi": { + // 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, mockRunBootStruct, bootDir, "EFI/boot/grubx64.efi", + &gadget.ContentChange{}) + c.Assert(err, IsNil) + c.Check(res, Equals, gadget.ChangeApply) + res, err = obs.Observe(gadget.ContentRollback, mockSeedStruct, seedDir, "EFI/boot/grubx64.efi", + &gadget.ContentChange{}) + c.Assert(err, IsNil) + c.Check(res, Equals, gadget.ChangeApply) + res, err = obs.Observe(gadget.ContentRollback, mockSeedStruct, 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": {"assethash"}, + "shim": {"shimhash"}, + }, + CurrentTrustedRecoveryBootAssets: boot.BootAssetsMap{ + "asset": {"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 = ioutil.WriteFile(filepath.Join(dirs.SnapBootAssetsDir, "trusted", name), nil, 0644) + c.Assert(err, IsNil) + } + + s.bootloaderWithTrustedAssets(c, []string{"asset", "shim"}) + + // we get an observer for UC20 + obs, _ := s.uc20UpdateObserverEncryptedSystemMockedBootloader(c) + + data := []byte("foobar") + // SHA3-384 + dataHash := "0fa8abfbdaf924ad307b74dd2ed183b9a4a398891a2f6bac8fd2db7041b77f068580f9c6c66f699b496c2da1cbcc7ed8" + err = ioutil.WriteFile(filepath.Join(d, "foobar"), data, 0644) + c.Assert(err, IsNil) + shim := []byte("shim") + shimHash := "dac0063e831d4b2e7a330426720512fc50fa315042f0bb30f9d1db73e4898dcb89119cac41fdfa62137c8931a50f9d7b" + err = ioutil.WriteFile(filepath.Join(d, "shim"), shim, 0644) + c.Assert(err, IsNil) + + res, err := obs.Observe(gadget.ContentUpdate, mockRunBootStruct, 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, mockRunBootStruct, 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, mockSeedStruct, 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, mockSeedStruct, 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": {"assethash", dataHash}, + "shim": {"shimhash", shimHash}, + }) + c.Check(newM.CurrentTrustedRecoveryBootAssets, DeepEquals, boot.BootAssetsMap{ + "asset": {"recoveryhash", dataHash}, + "shim": {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(c, []string{"asset", "shim"}) + + data := []byte("foobar") + // SHA3-384 + dataHash := "0fa8abfbdaf924ad307b74dd2ed183b9a4a398891a2f6bac8fd2db7041b77f068580f9c6c66f699b496c2da1cbcc7ed8" + err := ioutil.WriteFile(filepath.Join(d, "foobar"), data, 0644) + c.Assert(err, IsNil) + shim := []byte("shim") + shimHash := "dac0063e831d4b2e7a330426720512fc50fa315042f0bb30f9d1db73e4898dcb89119cac41fdfa62137c8931a50f9d7b" + err = ioutil.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 = ioutil.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": {"assethash"}, + "shim": {"shimhash"}, + }, + CurrentTrustedRecoveryBootAssets: boot.BootAssetsMap{ + "shim": {shimHash}, + }, + } + err = m.WriteTo("") + c.Assert(err, IsNil) + + res, err := obs.Observe(gadget.ContentUpdate, mockRunBootStruct, 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, mockRunBootStruct, 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, mockSeedStruct, 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": {"assethash", dataHash}, + "shim": {"shimhash", shimHash}, + }) + c.Check(newM.CurrentTrustedRecoveryBootAssets, DeepEquals, boot.BootAssetsMap{ + "asset": {dataHash}, + "shim": {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": {"assethash"}, + "shim": {"shimhash"}, + }, + CurrentTrustedRecoveryBootAssets: boot.BootAssetsMap{ + "asset": {"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 = ioutil.WriteFile(filepath.Join(dirs.SnapBootAssetsDir, "trusted", name), nil, 0644) + c.Assert(err, IsNil) + } + + s.bootloaderWithTrustedAssets(c, []string{"asset", "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 = ioutil.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, mockSeedStruct, 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(c, []string{"asset", "shim"}) + // we get an observer for UC20 + obs, _ := s.uc20UpdateObserverEncryptedSystemMockedBootloader(c) + + // trigger loading modeenv and bootloader information + err = ioutil.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, mockSeedStruct, 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) + + // get a new observer, and observe an update for run bootloader asset only + obs, _ = s.uc20UpdateObserverEncryptedSystemMockedBootloader(c) + res, err = obs.Observe(gadget.ContentUpdate, mockRunBootStruct, 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": {"assethash"}, + }, + CurrentTrustedRecoveryBootAssets: boot.BootAssetsMap{ + "asset": {"assethash"}, + }, + } + err := m.WriteTo("") + c.Assert(err, IsNil) + + s.bootloaderWithTrustedAssets(c, []string{"asset", "shim"}) + // we get an observer for UC20 + obs, _ := s.uc20UpdateObserverEncryptedSystemMockedBootloader(c) + + // trigger loading modeenv and bootloader information + err = ioutil.WriteFile(filepath.Join(d, "shim"), []byte("shim"), 0644) + c.Assert(err, IsNil) + res, err := obs.Observe(gadget.ContentUpdate, mockSeedStruct, 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, mockRunBootStruct, 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": {"assethash"}, + }, + CurrentTrustedRecoveryBootAssets: boot.BootAssetsMap{ + "asset": {"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 = ioutil.WriteFile(filepath.Join(dirs.SnapBootAssetsDir, "trusted", name), nil, 0644) + c.Assert(err, IsNil) + } + + s.bootloaderWithTrustedAssets(c, []string{"asset", "shim"}) + // we get an observer for UC20 + obs, _ := s.uc20UpdateObserverEncryptedSystemMockedBootloader(c) + + shim := []byte("shim") + shimHash := "dac0063e831d4b2e7a330426720512fc50fa315042f0bb30f9d1db73e4898dcb89119cac41fdfa62137c8931a50f9d7b" + err = ioutil.WriteFile(filepath.Join(d, "shim"), shim, 0644) + c.Assert(err, IsNil) + res, err := obs.Observe(gadget.ContentUpdate, mockSeedStruct, 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, mockRunBootStruct, 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": {"assethash"}, + "shim": {shimHash}, + }) + c.Check(newM.CurrentTrustedRecoveryBootAssets, DeepEquals, boot.BootAssetsMap{ + "asset": {"recoveryhash"}, + "shim": {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(c, []string{"asset"}) + + m := &boot.Modeenv{ + Mode: "run", + CurrentTrustedBootAssets: boot.BootAssetsMap{ + "asset": {"assethash"}, + }, + CurrentTrustedRecoveryBootAssets: boot.BootAssetsMap{ + "asset": {"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(c, []string{"asset", "shim"}) + + data := []byte("foobar") + // SHA3-384 + dataHash := "0fa8abfbdaf924ad307b74dd2ed183b9a4a398891a2f6bac8fd2db7041b77f068580f9c6c66f699b496c2da1cbcc7ed8" + shim := []byte("shim") + shimHash := "dac0063e831d4b2e7a330426720512fc50fa315042f0bb30f9d1db73e4898dcb89119cac41fdfa62137c8931a50f9d7b" + + // only asset for ubuntu-boot + c.Assert(ioutil.WriteFile(filepath.Join(boot.InitramfsUbuntuBootDir, "asset"), data, 0644), IsNil) + // shim and asset for ubuntu-seed + c.Assert(ioutil.WriteFile(filepath.Join(boot.InitramfsUbuntuSeedDir, "asset"), data, 0644), IsNil) + c.Assert(ioutil.WriteFile(filepath.Join(boot.InitramfsUbuntuSeedDir, "shim"), shim, 0644), IsNil) + + m := &boot.Modeenv{ + Mode: "run", + CurrentTrustedBootAssets: boot.BootAssetsMap{ + "asset": {"assethash", dataHash}, + }, + CurrentTrustedRecoveryBootAssets: boot.BootAssetsMap{ + "asset": {"recoveryassethash", dataHash}, + "shim": {"recoveryshimhash", shimHash}, + }, + } + + newM, drop, err := boot.ObserveSuccessfulBootWithAssets(m) + c.Assert(err, IsNil) + c.Assert(newM, NotNil) + c.Check(newM.CurrentTrustedBootAssets, DeepEquals, boot.BootAssetsMap{ + "asset": {dataHash}, + }) + c.Check(newM.CurrentTrustedRecoveryBootAssets, DeepEquals, boot.BootAssetsMap{ + "asset": {dataHash}, + "shim": {shimHash}, + }) + c.Check(drop, HasLen, 3) + for i, en := range []struct { + assetName, hash string + }{ + {"asset", "assethash"}, + {"asset", "recoveryassethash"}, + {"shim", "recoveryshimhash"}, + } { + c.Check(drop[i].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(c, []string{"asset"}) + + data := []byte("foobar") + dataHash := "0fa8abfbdaf924ad307b74dd2ed183b9a4a398891a2f6bac8fd2db7041b77f068580f9c6c66f699b496c2da1cbcc7ed8" + unexpected := []byte("unexpected") + unexpectedHash := "2c823b62c52e614e48faac7e8b1fbb8ff3aee4d06b6f7fe5bd7d64953162b6e9879ead4827fa19c8c9a514585ddac94c" + + // asset for ubuntu-boot + c.Assert(ioutil.WriteFile(filepath.Join(boot.InitramfsUbuntuBootDir, "asset"), unexpected, 0644), IsNil) + // and for ubuntu-seed + c.Assert(ioutil.WriteFile(filepath.Join(boot.InitramfsUbuntuSeedDir, "asset"), unexpected, 0644), IsNil) + + m := &boot.Modeenv{ + Mode: "run", + CurrentTrustedBootAssets: boot.BootAssetsMap{ + "asset": {"assethash", dataHash}, + }, + CurrentTrustedRecoveryBootAssets: boot.BootAssetsMap{ + "asset": {"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(ioutil.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(c, []string{"asset", "shim"}) + + data := []byte("foobar") + // SHA3-384 + dataHash := "0fa8abfbdaf924ad307b74dd2ed183b9a4a398891a2f6bac8fd2db7041b77f068580f9c6c66f699b496c2da1cbcc7ed8" + shim := []byte("shim") + shimHash := "dac0063e831d4b2e7a330426720512fc50fa315042f0bb30f9d1db73e4898dcb89119cac41fdfa62137c8931a50f9d7b" + + // only asset for ubuntu-boot + c.Assert(ioutil.WriteFile(filepath.Join(boot.InitramfsUbuntuBootDir, "asset"), data, 0644), IsNil) + // shim and asset for ubuntu-seed + c.Assert(ioutil.WriteFile(filepath.Join(boot.InitramfsUbuntuSeedDir, "asset"), data, 0644), IsNil) + c.Assert(ioutil.WriteFile(filepath.Join(boot.InitramfsUbuntuSeedDir, "shim"), shim, 0644), IsNil) + + m := &boot.Modeenv{ + Mode: "run", + CurrentTrustedBootAssets: boot.BootAssetsMap{ + "asset": {dataHash}, + }, + CurrentTrustedRecoveryBootAssets: boot.BootAssetsMap{ + "asset": {dataHash}, + "shim": {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(c, []string{"asset"}) + + maybeDrop := []byte("maybe-drop") + maybeDropHash := "08a99ce3af529ebbfb9a82df690007ac650635b165c3d1b416d471907fa3843270dce9cc001ea26f4afb4e0c5af05209" + data := []byte("foobar") + dataHash := "0fa8abfbdaf924ad307b74dd2ed183b9a4a398891a2f6bac8fd2db7041b77f068580f9c6c66f699b496c2da1cbcc7ed8" + + // ubuntu-boot booted with maybe-drop asset + c.Assert(ioutil.WriteFile(filepath.Join(boot.InitramfsUbuntuBootDir, "asset"), maybeDrop, 0644), IsNil) + + c.Assert(ioutil.WriteFile(filepath.Join(boot.InitramfsUbuntuSeedDir, "asset"), data, 0644), IsNil) + + m := &boot.Modeenv{ + Mode: "run", + CurrentTrustedBootAssets: boot.BootAssetsMap{ + "asset": {maybeDropHash}, + }, + CurrentTrustedRecoveryBootAssets: boot.BootAssetsMap{ + "asset": {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": {maybeDropHash}, + }) + c.Check(newM.CurrentTrustedRecoveryBootAssets, DeepEquals, boot.BootAssetsMap{ + "asset": {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(c, []string{"asset", "shim"}) + + data := []byte("foobar") + // SHA3-384 + dataHash := "0fa8abfbdaf924ad307b74dd2ed183b9a4a398891a2f6bac8fd2db7041b77f068580f9c6c66f699b496c2da1cbcc7ed8" + shim := []byte("shim") + shimHash := "dac0063e831d4b2e7a330426720512fc50fa315042f0bb30f9d1db73e4898dcb89119cac41fdfa62137c8931a50f9d7b" + + // only asset for ubuntu-boot + c.Assert(ioutil.WriteFile(filepath.Join(boot.InitramfsUbuntuBootDir, "asset"), data, 0644), IsNil) + // shim and asset for ubuntu-seed + c.Assert(ioutil.WriteFile(filepath.Join(boot.InitramfsUbuntuSeedDir, "asset"), data, 0644), IsNil) + c.Assert(ioutil.WriteFile(filepath.Join(boot.InitramfsUbuntuSeedDir, "shim"), shim, 0644), IsNil) + + m := &boot.Modeenv{ + Mode: "run", + CurrentTrustedBootAssets: boot.BootAssetsMap{ + "asset": {"oldhash", dataHash}, + }, + CurrentTrustedRecoveryBootAssets: boot.BootAssetsMap{ + "asset": {"oldhash", dataHash}, + "shim": {shimHash}, + }, + } + + newM, drop, err := boot.ObserveSuccessfulBootWithAssets(m) + c.Assert(err, IsNil) + c.Assert(newM, NotNil) + c.Check(newM.CurrentTrustedBootAssets, DeepEquals, boot.BootAssetsMap{ + "asset": {dataHash}, + }) + c.Check(newM.CurrentTrustedRecoveryBootAssets, DeepEquals, boot.BootAssetsMap{ + "asset": {dataHash}, + "shim": {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(c, []string{"asset"}) + + data := []byte("foobar") + dataHash := "0fa8abfbdaf924ad307b74dd2ed183b9a4a398891a2f6bac8fd2db7041b77f068580f9c6c66f699b496c2da1cbcc7ed8" + + c.Assert(ioutil.WriteFile(filepath.Join(boot.InitramfsUbuntuBootDir, "asset"), data, 0000), IsNil) + c.Assert(ioutil.WriteFile(filepath.Join(boot.InitramfsUbuntuSeedDir, "asset"), data, 0000), IsNil) + + m := &boot.Modeenv{ + Mode: "run", + CurrentTrustedBootAssets: boot.BootAssetsMap{ + "asset": {dataHash}, + }, + CurrentTrustedRecoveryBootAssets: boot.BootAssetsMap{ + "asset": {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) 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 = ioutil.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 = ioutil.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 = ioutil.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 = ioutil.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 := ioutil.WriteFile(filepath.Join(backups, "asset.backup"), before, 0644) + c.Assert(err, IsNil) + + data := []byte("foobar") + // SHA3-384 + dataHash := "0fa8abfbdaf924ad307b74dd2ed183b9a4a398891a2f6bac8fd2db7041b77f068580f9c6c66f699b496c2da1cbcc7ed8" + err = ioutil.WriteFile(filepath.Join(d, "foobar"), data, 0644) + c.Assert(err, IsNil) + shim := []byte("shim") + shimHash := "dac0063e831d4b2e7a330426720512fc50fa315042f0bb30f9d1db73e4898dcb89119cac41fdfa62137c8931a50f9d7b" + err = ioutil.WriteFile(filepath.Join(d, "shim"), shim, 0644) + c.Assert(err, IsNil) + + m := boot.Modeenv{ + Mode: "run", + CurrentTrustedBootAssets: boot.BootAssetsMap{ + "asset": {beforeHash}, + }, + CurrentTrustedRecoveryBootAssets: boot.BootAssetsMap{ + "asset": {beforeHash}, + }, + CurrentRecoverySystems: []string{"recovery-system-label"}, + CurrentKernels: []string{"pc-kernel_500.snap"}, + } + err = m.WriteTo("") + c.Assert(err, IsNil) + + tab := s.bootloaderWithTrustedAssets(c, []string{ + "asset", + "shim", + }) + + // we get an observer for UC20 + obs, uc20model := s.uc20UpdateObserverEncryptedSystemMockedBootloader(c) + + res, err := obs.Observe(gadget.ContentUpdate, mockRunBootStruct, 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, mockRunBootStruct, 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, mockSeedStruct, 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, mockSeedStruct, 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) { + kernelSnap := &seed.Snap{ + Path: "/var/lib/snapd/seed/snaps/pc-kernel_1.snap", + SideInfo: &snap.SideInfo{ + Revision: snap.Revision{N: 1}, + RealName: "pc-kernel", + }, + } + return uc20model, []*seed.Snap{kernelSnap}, 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, DeepEquals, uc20model) + 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(beforeAssetBf, + secboot.NewLoadChain(recoveryKernelBf))), + secboot.NewLoadChain(shimBf, + secboot.NewLoadChain(assetBf, + secboot.NewLoadChain(runKernelBf)), + secboot.NewLoadChain(beforeAssetBf, + secboot.NewLoadChain(runKernelBf))), + }) + case 2: + c.Check(mp.EFILoadChains, DeepEquals, []*secboot.LoadChain{ + secboot.NewLoadChain(shimBf, + secboot.NewLoadChain(assetBf, + secboot.NewLoadChain(recoveryKernelBf)), + secboot.NewLoadChain(beforeAssetBf, + 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() + + m := boot.Modeenv{ + Mode: "run", + CurrentTrustedBootAssets: boot.BootAssetsMap{ + "asset": {"assethash"}, + "shim": {"shimhash"}, + }, + CurrentTrustedRecoveryBootAssets: boot.BootAssetsMap{ + "asset": {"assethash"}, + "shim": {"shimhash"}, + }, + CurrentRecoverySystems: []string{"system"}, + CurrentKernels: []string{"pc-kernel_1.snap"}, + } + 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 = ioutil.WriteFile(filepath.Join(dirs.SnapBootAssetsDir, "trusted", name), nil, 0644) + c.Assert(err, IsNil) + } + + tab := s.bootloaderWithTrustedAssets(c, []string{"asset", "shim"}) + + // we get an observer for UC20 + obs, uc20model := s.uc20UpdateObserverEncryptedSystemMockedBootloader(c) + + data := []byte("foobar") + err = ioutil.WriteFile(filepath.Join(d, "foobar"), data, 0644) + c.Assert(err, IsNil) + shim := []byte("shim") + err = ioutil.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, mockRunBootStruct, 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, mockRunBootStruct, 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, mockSeedStruct, 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, mockSeedStruct, 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) { + kernelSnap := &seed.Snap{ + Path: "/var/lib/snapd/seed/snaps/pc-kernel_1.snap", + SideInfo: &snap.SideInfo{ + Revision: snap.Revision{N: 1}, + RealName: "pc-kernel", + }, + } + return uc20model, []*seed.Snap{kernelSnap}, 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, DeepEquals, uc20model) + 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 := ioutil.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(c, []string{ + "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, mockRunBootStruct, root, "asset", change) + c.Assert(err, IsNil) + c.Check(res, Equals, gadget.ChangeApply) + res, err = obs.Observe(gadget.ContentUpdate, mockSeedStruct, 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, mockRunBootStruct, 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, mockSeedStruct, 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..91836f49 --- /dev/null +++ b/boot/boot.go @@ -0,0 +1,397 @@ +// -*- 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 ( + "errors" + "fmt" + + "github.com/snapcore/snapd/asserts" + "github.com/snapcore/snapd/bootloader" + "github.com/snapcore/snapd/snap" +) + +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" +) + +// 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. 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() (rebootRequired bool, 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() (bool, error) { return 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{} + +// Device carries information about the device model and mode that is +// relevant to boot. Note snapstate.DeviceContext implements this, and that's +// the expected use case. +type Device interface { + RunMode() bool + Classic() bool + + Kernel() string + Base() string + + HasModeenv() bool + + Model() *asserts.Model +} + +// 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 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 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 Device) BootKernel { + if t == snap.TypeKernel && applicable(s, t, dev) { + return &coreKernel{s: s, bopts: bootloaderOptionsForDeviceKernel(dev)} + } + return trivial{} +} + +func applicable(s snap.PlaceInfo, t snap.Type, dev Device) bool { + if dev.Classic() { + 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 + } + + if t != snap.TypeOS && t != snap.TypeKernel && t != snap.TypeBase { + // note we don't currently have anything useful to do with gadgets + return false + } + + switch t { + case snap.TypeKernel: + if s.InstanceName() != dev.Kernel() { + // a remodel might leave you in this state + return false + } + case snap.TypeBase, snap.TypeOS: + base := dev.Base() + if base == "" { + base = "core" + } + if s.InstanceName() != base { + 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. actually committing the update + // is done via the returned bootStateUpdate's commit method. + setNext(s snap.PlaceInfo) (rebootRequired bool, 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 Device) (s bootState, err error) { + if !dev.RunMode() { + return nil, fmt.Errorf("internal error: no boot state handling for ephemeral modes") + } + newBootState := newBootState16 + if dev.HasModeenv() { + newBootState = newBootState20 + } + switch typ { + case snap.TypeOS, snap.TypeBase: + return newBootState(snap.TypeBase, dev), nil + case snap.TypeKernel: + return newBootState(snap.TypeKernel, dev), nil + default: + 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 Device) (InUseFunc, error) { + if dev.Classic() { + // no boot state on classic + return fixedInUse(false), nil + } + if !dev.RunMode() { + // ephemeral mode, block manipulations for now + return fixedInUse(true), nil + } + switch typ { + case snap.TypeKernel, snap.TypeBase, snap.TypeOS: + break + default: + 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 Device) (snap.PlaceInfo, error) { + 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 Device) error { + const errPrefix = "cannot mark boot successful: %s" + + var u bootStateUpdate + for _, t := range []snap.Type{snap.TypeBase, snap.TypeKernel} { + 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), + } { + 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 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) +} diff --git a/boot/boot_robustness_test.go b/boot/boot_robustness_test.go new file mode 100644 index 00000000..b948eb48 --- /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 boot.Device, + bootFunc func(boot.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.SetRunKernelImagePanic(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(boot.Device) error { + // we don't care about the reboot required logic here + _, err := bootKern.SetNextBoot() + 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..e20d0b93 --- /dev/null +++ b/boot/boot_test.go @@ -0,0 +1,2843 @@ +// -*- 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 boot_test + +import ( + "errors" + "fmt" + "io/ioutil" + "os" + "path/filepath" + "testing" + + . "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/dirs" + "github.com/snapcore/snapd/osutil" + "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/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) + + 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 = osutil.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 := ioutil.WriteFile(stamp, nil, 0644) + c.Assert(err, IsNil) +} + +func (s *baseBootenvSuite) mockCmdline(c *C, cmdline string) { + c.Assert(ioutil.WriteFile(s.cmdlineFile, []byte(cmdline), 0644), 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 + + 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) + + // 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, + // 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, + // 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 +} + +var defaultUC20BootEnv = map[string]string{"kernel_status": boot.DefaultStatus} + +var _ = Suite(&bootenv20Suite{}) +var _ = Suite(&bootenv20EnvRefKernelSuite{}) + +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) +} + +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: + // then we need to use the bootenv to set the current kernels + origEnv, err := vbl.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 = vbl.SetBootVars(m) + c.Assert(err, IsNil) + + // don't count any calls to SetBootVars made thus far + vbl.SetBootVarsCalls = 0 + + cleanups = append(cleanups, func() { + err := bl.SetBootVars(origEnv) + c.Assert(err, IsNil) + }) + default: + c.Fatalf("unsupported bootloader %T", bl) + } + + return func() { + for _, r := range cleanups { + r() + } + } +} + +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 *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"`) + + // sanity 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) 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} + + 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, + }, + } + + 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) 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() + c.Assert(err, IsNil) + c.Assert(rebootRequired, Equals, 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() + c.Assert(err, IsNil) + c.Assert(rebootRequired, Equals, 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() + c.Assert(err, IsNil) + c.Assert(rebootRequired, Equals, true) + + // 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, []string{"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(ioutil.WriteFile(filepath.Join(boot.InitramfsUbuntuBootDir, "asset"), data, 0644), IsNil) + c.Assert(ioutil.WriteFile(filepath.Join(boot.InitramfsUbuntuSeedDir, "asset"), data, 0644), IsNil) + + // mock the files in cache + c.Assert(os.MkdirAll(filepath.Join(dirs.SnapBootAssetsDir, "trusted"), 0755), IsNil) + for _, name := range []string{ + "asset-" + dataHash, + } { + c.Assert(ioutil.WriteFile(filepath.Join(dirs.SnapBootAssetsDir, "trusted", name), nil, 0644), IsNil) + } + + 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) + + m := &boot.Modeenv{ + Mode: "run", + Base: s.base1.Filename(), + CurrentKernels: []string{s.kern1.Filename()}, + CurrentTrustedBootAssets: boot.BootAssetsMap{ + "asset": {dataHash}, + }, + } + + 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, DeepEquals, coreDev.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() + c.Assert(err, IsNil) + c.Assert(rebootRequired, Equals, true) + + // 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, []string{"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(ioutil.WriteFile(filepath.Join(boot.InitramfsUbuntuBootDir, "asset"), data, 0644), IsNil) + c.Assert(ioutil.WriteFile(filepath.Join(boot.InitramfsUbuntuSeedDir, "asset"), data, 0644), IsNil) + + // mock the files in cache + c.Assert(os.MkdirAll(filepath.Join(dirs.SnapBootAssetsDir, "trusted"), 0755), IsNil) + for _, name := range []string{ + "asset-" + dataHash, + } { + c.Assert(ioutil.WriteFile(filepath.Join(dirs.SnapBootAssetsDir, "trusted", name), nil, 0644), IsNil) + } + + 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": {dataHash}, + }, + } + + 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, DeepEquals, uc20Model) + 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() + c.Assert(err, IsNil) + c.Assert(rebootRequired, Equals, true) + + 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, []string{"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(ioutil.WriteFile(filepath.Join(boot.InitramfsUbuntuBootDir, "asset"), data, 0644), IsNil) + c.Assert(ioutil.WriteFile(filepath.Join(boot.InitramfsUbuntuSeedDir, "asset"), data, 0644), IsNil) + + // mock the files in cache + c.Assert(os.MkdirAll(filepath.Join(dirs.SnapBootAssetsDir, "trusted"), 0755), IsNil) + for _, name := range []string{ + "asset-" + dataHash, + } { + c.Assert(ioutil.WriteFile(filepath.Join(dirs.SnapBootAssetsDir, "trusted", name), nil, 0644), IsNil) + } + + 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": {dataHash}, + }, + } + + 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() + c.Assert(err, IsNil) + c.Assert(rebootRequired, Equals, 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, []string{"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(ioutil.WriteFile(filepath.Join(boot.InitramfsUbuntuBootDir, "asset"), data, 0644), IsNil) + c.Assert(ioutil.WriteFile(filepath.Join(boot.InitramfsUbuntuSeedDir, "asset"), data, 0644), IsNil) + + // mock the files in cache + c.Assert(os.MkdirAll(filepath.Join(dirs.SnapBootAssetsDir, "trusted"), 0755), IsNil) + for _, name := range []string{ + "asset-" + dataHash, + } { + c.Assert(ioutil.WriteFile(filepath.Join(dirs.SnapBootAssetsDir, "trusted", name), nil, 0644), IsNil) + } + + 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": {dataHash}, + }, + } + + 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() + c.Assert(err, IsNil) + c.Assert(rebootRequired, Equals, 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() + c.Assert(err, IsNil) + c.Assert(rebootRequired, Equals, true) + + // 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() + c.Assert(err, IsNil) + + // we don't need to reboot because it's the same base snap + c.Assert(rebootRequired, Equals, 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() + c.Assert(err, IsNil) + c.Assert(rebootRequired, Equals, 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) + + tab := s.bootloaderWithTrustedAssets(c, []string{"asset"}) + + coreDev := boottest.MockUC20Device("", nil) + c.Assert(coreDev.HasModeenv(), Equals, true) + + resealCalls := 0 + restore := boot.MockSecbootResealKeys(func(params *secboot.ResealKeysParams) error { + resealCalls++ + return nil + }) + defer restore() + + // we should not even need to build boot chains + tab.BootChainErr = errors.New("boom") + + // default state + m := &boot.Modeenv{ + Mode: "run", + Base: s.base1.Filename(), + } + 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() + c.Assert(err, IsNil) + c.Assert(rebootRequired, Equals, 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, []string{"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(ioutil.WriteFile(filepath.Join(boot.InitramfsUbuntuBootDir, "asset"), data, 0644), IsNil) + c.Assert(ioutil.WriteFile(filepath.Join(boot.InitramfsUbuntuSeedDir, "asset"), data, 0644), IsNil) + + // mock the files in cache + c.Assert(os.MkdirAll(filepath.Join(dirs.SnapBootAssetsDir, "trusted"), 0755), IsNil) + for _, name := range []string{ + "asset-" + dataHash, + } { + c.Assert(ioutil.WriteFile(filepath.Join(dirs.SnapBootAssetsDir, "trusted", name), nil, 0644), IsNil) + } + + 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), + runKernelBf, + } + + // 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": {dataHash}, + }, + } + r := setupUC20Bootenv( + c, + tab.MockBootloader, + &bootenv20Setup{ + modeenv: m, + kern: s.kern1, + tryKern: s.kern2, + kernStatus: boot.TryingStatus, + }, + ) + defer r() + + coreDev := boottest.MockUC20Device("", nil) + 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, DeepEquals, coreDev.Model()) + for _, ch := range mp.EFILoadChains { + printChain(c, ch, "-") + } + c.Check(mp.EFILoadChains, DeepEquals, []*secboot.LoadChain{ + secboot.NewLoadChain(assetBf, + secboot.NewLoadChain(runKernelBf)), + }) + return nil + }) + defer restore() + + // 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": boot.DefaultStatus, + } + c.Assert(tab.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()}) + + c.Check(resealCalls, Equals, 1) +} + +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 []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.TrustedAssetsList = 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, []string{"asset", "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(ioutil.WriteFile(filepath.Join(boot.InitramfsUbuntuBootDir, "asset"), data, 0644), IsNil) + // shim and asset for seed + c.Assert(ioutil.WriteFile(filepath.Join(boot.InitramfsUbuntuSeedDir, "asset"), data, 0644), IsNil) + c.Assert(ioutil.WriteFile(filepath.Join(boot.InitramfsUbuntuSeedDir, "shim"), shim, 0644), IsNil) + + // mock the files in cache + c.Assert(os.MkdirAll(filepath.Join(dirs.SnapBootAssetsDir, "trusted"), 0755), IsNil) + for _, name := range []string{ + "shim-recoveryshimhash", + "shim-" + shimHash, + "asset-assethash", + "asset-recoveryassethash", + "asset-" + dataHash, + } { + c.Assert(ioutil.WriteFile(filepath.Join(dirs.SnapBootAssetsDir, "trusted", name), nil, 0644), IsNil) + } + + 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("/var/lib/snapd/seed/snaps/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) { + kernelSnap := &seed.Snap{ + Path: "/var/lib/snapd/seed/snaps/pc-linux_1.snap", + SideInfo: &snap.SideInfo{ + Revision: snap.Revision{N: 1}, + RealName: "pc-linux", + }, + } + return uc20Model, []*seed.Snap{kernelSnap}, 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": {"assethash", dataHash}, + }, + CurrentTrustedRecoveryBootAssets: boot.BootAssetsMap{ + "asset": {"recoveryassethash", dataHash}, + "shim": {"recoveryshimhash", shimHash}, + }, + CurrentRecoverySystems: []string{"system"}, + } + 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, DeepEquals, uc20Model) + 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": {dataHash}, + }) + c.Check(m2.CurrentTrustedRecoveryBootAssets, DeepEquals, boot.BootAssetsMap{ + "asset": {dataHash}, + "shim": {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, []string{"nested/asset", "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(ioutil.WriteFile(filepath.Join(boot.InitramfsUbuntuBootDir, "nested/asset"), data, 0644), IsNil) + // shim and asset for ubuntu-seed + c.Assert(ioutil.WriteFile(filepath.Join(boot.InitramfsUbuntuSeedDir, "nested/asset"), data, 0644), IsNil) + c.Assert(ioutil.WriteFile(filepath.Join(boot.InitramfsUbuntuSeedDir, "shim"), shim, 0644), IsNil) + + // mock the files in cache + c.Assert(os.MkdirAll(filepath.Join(dirs.SnapBootAssetsDir, "trusted"), 0755), IsNil) + for _, name := range []string{ + "shim-" + shimHash, + "asset-" + dataHash, + } { + c.Assert(ioutil.WriteFile(filepath.Join(dirs.SnapBootAssetsDir, "trusted", name), nil, 0644), IsNil) + } + + 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("", "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) { + kernelSnap := &seed.Snap{ + Path: "/var/lib/snapd/seed/snaps/pc-kernel-recovery_1.snap", + SideInfo: &snap.SideInfo{ + Revision: snap.Revision{N: 1}, + RealName: "pc-kernel-recovery", + }, + } + return uc20Model, []*seed.Snap{kernelSnap}, 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": {dataHash}, + }, + CurrentTrustedRecoveryBootAssets: boot.BootAssetsMap{ + "asset": {dataHash}, + "shim": {shimHash}, + }, + CurrentRecoverySystems: []string{"system"}, + } + 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=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, []string{"nested/asset", "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(ioutil.WriteFile(filepath.Join(boot.InitramfsUbuntuBootDir, "nested/asset"), data, 0644), IsNil) + // shim and asset for ubuntu-seed + c.Assert(ioutil.WriteFile(filepath.Join(boot.InitramfsUbuntuSeedDir, "nested/asset"), data, 0644), IsNil) + c.Assert(ioutil.WriteFile(filepath.Join(boot.InitramfsUbuntuSeedDir, "shim"), shim, 0644), IsNil) + + // mock the files in cache + c.Assert(os.MkdirAll(filepath.Join(dirs.SnapBootAssetsDir, "trusted"), 0755), IsNil) + for _, name := range []string{ + "shim-" + shimHash, + "asset-" + dataHash, + } { + c.Assert(ioutil.WriteFile(filepath.Join(dirs.SnapBootAssetsDir, "trusted", name), nil, 0644), IsNil) + } + + 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("", "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) { + kernelSnap := &seed.Snap{ + Path: "/var/lib/snapd/seed/snaps/pc-kernel-recovery_1.snap", + SideInfo: &snap.SideInfo{ + Revision: snap.Revision{N: 1}, + RealName: "pc-kernel-recovery", + }, + } + return uc20Model, []*seed.Snap{kernelSnap}, 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": {dataHash}, + }, + CurrentTrustedRecoveryBootAssets: boot.BootAssetsMap{ + "asset": {dataHash}, + "shim": {shimHash}, + }, + CurrentRecoverySystems: []string{"system"}, + CurrentKernelCommandLines: boot.BootCommandLines{"snapd_recovery_mode=run"}, + } + 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=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, []string{"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(ioutil.WriteFile(filepath.Join(boot.InitramfsUbuntuBootDir, "EFI/asset"), data, 0644), IsNil) + c.Assert(ioutil.WriteFile(filepath.Join(boot.InitramfsUbuntuSeedDir, "EFI/asset"), data, 0644), IsNil) + // mock some state in the cache + c.Assert(os.MkdirAll(filepath.Join(dirs.SnapBootAssetsDir, "trusted"), 0755), IsNil) + for _, name := range []string{"asset-one", "asset-two"} { + c.Assert(ioutil.WriteFile(filepath.Join(dirs.SnapBootAssetsDir, "trusted", name), nil, 0644), IsNil) + } + + // 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": {"one", "two"}, + }, + CurrentTrustedRecoveryBootAssets: boot.BootAssetsMap{ + "asset": {"one", "two"}, + }, + } + r := setupUC20Bootenv( + c, + tab.MockBootloader, + &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, 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, mode string, cmdlines boot.BootCommandLines) *boot.Modeenv { + // mock some state in the cache + c.Assert(os.MkdirAll(filepath.Join(dirs.SnapBootAssetsDir, "trusted"), 0755), IsNil) + c.Assert(ioutil.WriteFile(filepath.Join(dirs.SnapBootAssetsDir, "trusted", "asset-one"), nil, 0644), IsNil) + // a pending kernel command line change + m := &boot.Modeenv{ + Mode: mode, + Base: s.base1.Filename(), + CurrentKernels: []string{s.kern1.Filename()}, + CurrentTrustedBootAssets: boot.BootAssetsMap{ + "asset": {"one"}, + }, + CurrentTrustedRecoveryBootAssets: boot.BootAssetsMap{ + "asset": {"one"}, + }, + CurrentKernelCommandLines: cmdlines, + } + return m +} + +func (s *bootenv20Suite) TestMarkBootSuccessful20CommandLineUpdatedHappy(c *C) { + s.mockCmdline(c, "snapd_recovery_mode=run candidate panic=-1") + tab := s.bootloaderWithTrustedAssets(c, []string{"asset"}) + m := s.setupMarkBootSuccessful20CommandLine(c, "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() + + 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) + // 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, []string{"asset"}) + m := s.setupMarkBootSuccessful20CommandLine(c, "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() + + 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) + // 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, []string{"asset"}) + m := s.setupMarkBootSuccessful20CommandLine(c, "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() + + coreDev := boottest.MockUC20Device("", nil) + c.Assert(coreDev.HasModeenv(), Equals, true) + // 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, []string{"asset"}) + tab.StaticCommandLine = "panic=-1" + m := s.setupMarkBootSuccessful20CommandLine(c, "run", nil) + r := setupUC20Bootenv( + c, + tab.MockBootloader, + &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) + // 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, []string{"asset"}) + tab.StaticCommandLine = "panic=-1" + m := s.setupMarkBootSuccessful20CommandLine(c, "run", nil) + r := setupUC20Bootenv( + c, + tab.MockBootloader, + &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, 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, []string{"asset"}) + tab.StaticCommandLine = "panic=-1" + // current command line does not match any of the run mode command lines + m := s.setupMarkBootSuccessful20CommandLine(c, "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() + + 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) + // modeenv is unchaged + c.Check(m2.CurrentKernelCommandLines, DeepEquals, boot.BootCommandLines{ + "snapd_recovery_mode=run panic=-1", + "snapd_recovery_mode=run candidate panic=-1", + }) +} + +type recoveryBootenv20Suite struct { + baseBootenvSuite + + bootloader *bootloadertest.MockBootloader + + dev boot.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 = ioutil.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", + }) +} diff --git a/boot/bootchain.go b/boot/bootchain.go new file mode 100644 index 00000000..1c01b699 --- /dev/null +++ b/boot/bootchain.go @@ -0,0 +1,331 @@ +// -*- 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 ( + "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"` + 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"` + + model *asserts.Model + kernelBootFile bootloader.BootFile +} + +// 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 toPredictableBootAsset(b *bootAsset) *bootAsset { + if b == nil { + return nil + } + newB := *b + if b.Hashes != nil { + newB.Hashes = make([]string, len(b.Hashes)) + copy(newB.Hashes, b.Hashes) + sort.Strings(newB.Hashes) + } + return &newB +} + +func toPredictableBootChain(b *bootChain) *bootChain { + if b == nil { + return nil + } + newB := *b + if b.AssetChain != nil { + newB.AssetChain = make([]bootAsset, len(b.AssetChain)) + for i := range b.AssetChain { + newB.AssetChain[i] = *toPredictableBootAsset(&b.AssetChain[i]) + } + } + 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. +func bootAssetsToLoadChains(assets []bootAsset, kernelBootFile bootloader.BootFile, roleToBlName map[bootloader.Role]string) ([]*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 _, hash := range thisAsset.Hashes { + 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) + 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..97ceee59 --- /dev/null +++ b/boot/bootchain_test.go @@ -0,0 +1,1236 @@ +// -*- 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 ( + "encoding/json" + "io/ioutil" + "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/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) TestBootAssetsPredictable(c *C) { + // by role + ba := boot.BootAsset{ + Role: bootloader.RoleRunMode, Name: "list", Hashes: []string{"b", "a"}, + } + pred := boot.ToPredictableBootAsset(&ba) + c.Check(pred, DeepEquals, &boot.BootAsset{ + Role: bootloader.RoleRunMode, Name: "list", Hashes: []string{"a", "b"}, + }) + // original structure is not changed + c.Check(ba, DeepEquals, boot.BootAsset{ + Role: bootloader.RoleRunMode, Name: "list", Hashes: []string{"b", "a"}, + }) + + // try to make a predictable struct predictable once more + predAgain := boot.ToPredictableBootAsset(pred) + c.Check(predAgain, DeepEquals, pred) + + baNil := boot.ToPredictableBootAsset(nil) + c.Check(baNil, IsNil) +} + +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{"d", "e"}}, + {Role: bootloader.RoleRunMode, Name: "loader", Hashes: []string{"c", "d"}}, + {Role: bootloader.RoleRunMode, Name: "1oader", Hashes: []string{"d", "e"}}, + {Role: bootloader.RoleRunMode, Name: "0oader", Hashes: []string{"x", "z"}}, + }, + }) + + // 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`}, + } + + uc20model := boottest.MakeMockUC20Model() + bc.SetModelAssertion(uc20model) + 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{"a", "b"}}, + {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.SetModelAssertion(uc20model) + 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":["a","b"]},{"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.SetModelAssertion(uc20model) + 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{}{"x", "y"}}, + 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{}{"x", "y"}}, + map[string]interface{}{"role": "recovery", "name": "loader", "hashes": []interface{}{"c", "d"}}, + map[string]interface{}{"role": "run-mode", "name": "loader", "hashes": []interface{}{"a", "b"}}, + }, + }, { + "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{}{"x", "z"}}, + }, + }, + }) +} + +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{"a", "b"}}, + }, + 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{"a", "b"}}, + {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) + 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) + 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(ioutil.WriteFile(cPath("recovery-bl/shim-hash0"), nil, 0644), IsNil) + + // nested error bubbled up + chains, err = boot.BootAssetsToLoadChains(assets, kbl, blNames) + 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(ioutil.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) + 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(ioutil.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) + 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 + for _, name := range []string{ + "recovery-bl/shim-hash0", "recovery-bl/shim-hash1", + "recovery-bl/loader-recovery-hash0", + "recovery-bl/loader-recovery-hash1", + "run-bl/loader-run-hash0", + "run-bl/loader-run-hash1", + } { + p := filepath.Join(dirs.SnapBootAssetsDir, name) + c.Assert(os.MkdirAll(filepath.Dir(p), 0755), IsNil) + c.Assert(ioutil.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) + 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-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/shim-hash1"), 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-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))))), + } + 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":["x","y"]},{"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":["x","z"]}],"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) +} 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..884eeaf4 --- /dev/null +++ b/boot/bootstate16.go @@ -0,0 +1,197 @@ +// -*- 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 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) (rebootRequired bool, u bootStateUpdate, err error) { + nextBoot := s.Filename() + + 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 false, nil, err + } + + env := u16.env + toCommit := u16.toCommit + + snapMode := TryStatus + rebootRequired = true + 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 false, nil, nil + } + // clean + snapMode = DefaultStatus + nextBoot = "" + rebootRequired = false + } + + toCommit["snap_mode"] = snapMode + toCommit[nextBootVar] = nextBoot + + return rebootRequired, u16, nil +} diff --git a/boot/bootstate20.go b/boot/bootstate20.go new file mode 100644 index 00000000..4a5269d6 --- /dev/null +++ b/boot/bootstate20.go @@ -0,0 +1,756 @@ +// -*- 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" + "path/filepath" + + "github.com/snapcore/snapd/asserts" + "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 Device) bootState { + switch typ { + case snap.TypeBase: + return &bootState20Base{} + case snap.TypeKernel: + return &bootState20Kernel{ + dev: dev, + } + default: + panic(fmt.Sprintf("cannot make a bootState20 for snap type %q", typ)) + } +} + +func loadModeenv() (*Modeenv, error) { + modeenv, err := ReadModeenv("") + if err != nil { + return nil, fmt.Errorf("cannot get snap revision: unable to read modeenv: %v", err) + } + return modeenv, 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 +} + +// +// 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 + + // model set if a reseal might be necessary + resealModel *asserts.Model +} + +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 (u20 *bootStateUpdate20) resealForModel(model *asserts.Model) { + u20.resealModel = model +} + +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 { + // 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 + } + } + + modeenvRewritten := false + // next write the modeenv if it changed + if !u20.writeModeenv.deepEqual(u20.modeenv) { + if err := u20.writeModeenv.Write(); err != nil { + return err + } + modeenvRewritten = true + } + + // 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 u20.resealModel != nil { + // 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 + expectReseal := modeenvRewritten + if err := resealKeyToModeenv(dirs.GlobalRootDir, u20.resealModel, u20.writeModeenv, expectReseal); 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 + + // used to find the bootloader to manipulate the enabled kernel, etc. + blOpts *bootloader.Options + blDir string + + dev Device +} + +func (ks20 *bootState20Kernel) loadBootenv() error { + // don't setup multiple times + if ks20.bks != nil { + return nil + } + + // find the run-mode bootloader + var opts *bootloader.Options + if ks20.blOpts != nil { + opts = ks20.blOpts + } else { + opts = &bootloader.Options{ + Role: bootloader.RoleRunMode, + } + } + bl, err := bootloader.Find(ks20.blDir, opts) + 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} + } + + // 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, "", 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()} + + // keep track of the model for resealing + u20.resealForModel(ks20.dev.Model()) + } + + return u20, nil +} + +func (ks20 *bootState20Kernel) setNext(next snap.PlaceInfo) (rebootRequired bool, u bootStateUpdate, err error) { + u20, nextStatus, err := genericSetNext(ks20, next) + if err != nil { + return false, nil, err + } + + // if we are setting a snap as a try snap, then we need to reboot + rebootRequired = false + if nextStatus == TryStatus { + rebootRequired = true + } + + currentKernel := ks20.bks.kernel() + if next.Filename() != currentKernel.Filename() { + // on commit, add this kernel to the modeenv + u20.writeModeenv.CurrentKernels = append( + u20.writeModeenv.CurrentKernels, + next.Filename(), + ) + } + + // 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(func() error { return ks20.bks.setNextKernel(next, nextStatus) }) + + // keep track of the model for resealing + u20.resealForModel(ks20.dev.Model()) + + return rebootRequired, 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) (sn snap.PlaceInfo, err error) { + // first do the generic choice of which snap to use + first, second, err := genericInitramfsSelectSnap(ks20, modeenv, TryingStatus, "kernel") + if err != nil && err != errTrySnapFallback { + return nil, err + } + + 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()) +} + +// +// 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, "", 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) (rebootRequired bool, u bootStateUpdate, err error) { + u20, nextStatus, err := genericSetNext(bs20, next) + if err != nil { + return false, nil, err + } + + // if we are setting a snap as a try snap, then we need to reboot + rebootRequired = false + if nextStatus == TryStatus { + // only update the try base if we are actually in try status + u20.writeModeenv.TryBase = next.Filename() + rebootRequired = true + } + + // always update the base status + u20.writeModeenv.BaseStatus = nextStatus + + return rebootRequired, 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) (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, 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, setStatus string, err error) { + u20, err = newBootStateUpdate20(nil) + if err != nil { + return nil, "", err + } + + // get the current snap + current, _, _, err := b.revisionsFromModeenv(u20.modeenv) + if err != nil { + return nil, "", 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, DefaultStatus, nil + } + + // by default we will set the status as "try" to prepare for an update, + // which also by default will require a reboot + return u20, TryStatus, 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, expectedTryStatus, typeString string) ( + firstChoice, secondChoice snap.PlaceInfo, + err error, +) { + curSnap, trySnap, snapTryStatus, err := bs.revisionsFromModeenv(modeenv) + + if err != nil && !isTrySnapError(err) { + // we have no fallback snap! + return nil, nil, fmt.Errorf("fallback %s snap unusable: %v", typeString, err) + } + + // check that the current snap actually exists + file := curSnap.Filename() + snapPath := filepath.Join(dirs.SnapBlobDirUnder(InitramfsWritableDir), 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 err != nil && isTrySnapError(err) { + // just log that we had issues with the try snap and continue with + // using the normal snap + logger.Noticef("unable to process try %s snap: %v", typeString, err) + return curSnap, nil, errTrySnapFallback + } + if snapTryStatus != expectedTryStatus { + // the status is unexpected, log if its value is invalid and continue + // with the normal snap + fallbackErr := errTrySnapFallback + switch snapTryStatus { + case DefaultStatus: + fallbackErr = nil + case TryStatus, TryingStatus: + default: + 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(InitramfsWritableDir), 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 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 + // keep track of the model for resealing + u20.resealForModel(ba20.dev.Model()) + + 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 Device) *bootState20BootAssets { + return &bootState20BootAssets{ + dev: dev, + } +} + +// bootState20CommandLine implements the successfulBootState interface for +// kernel command line +type bootState20CommandLine struct { + dev Device +} + +func (ba20 *bootState20CommandLine) 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 { + // XXX: does this change when we expose the ability to add + // things to the command line? + + // not using trusted boot assets, bootloader config is not + // managed and command line can be manipulated externally + return update, nil + } + + newM, err := observeSuccessfulCommandLine(ba20.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 Device) *bootState20CommandLine { + return &bootState20CommandLine{ + dev: dev, + } +} diff --git a/boot/bootstate20_bloader_kernel_state.go b/boot/bootstate20_bloader_kernel_state.go new file mode 100644 index 00000000..ff95b1dc --- /dev/null +++ b/boot/bootstate20_bloader_kernel_state.go @@ -0,0 +1,295 @@ +// -*- 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 +} + +// 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 +} diff --git a/boot/boottest/bootenv.go b/boot/boottest/bootenv.go new file mode 100644 index 00000000..aefc0af4 --- /dev/null +++ b/boot/boottest/bootenv.go @@ -0,0 +1,169 @@ +// -*- 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 true +} + +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 +} diff --git a/boot/boottest/device.go b/boot/boottest/device.go new file mode 100644 index 00000000..9f5ffab0 --- /dev/null +++ b/boot/boottest/device.go @@ -0,0 +1,96 @@ +// -*- 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/boot" +) + +type mockDevice struct { + bootSnap string + mode string + uc20 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 both Base and Kernel, for more +// control mock a DeviceContext. +func MockDevice(s string) boot.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, + uc20: uc20, + } +} + +// MockUC20Device implements boot.Device and returns true for HasModeenv. +// Arguments are mode (empty means "run"), and model. +// If model is nil a default model is used (same as MakeMockUC20Model). +func MockUC20Device(mode string, model *asserts.Model) boot.Device { + if mode == "" { + mode = "run" + } + if model == nil { + model = MakeMockUC20Model() + } + return &mockDevice{ + bootSnap: model.Kernel(), + mode: mode, + uc20: true, + model: model, + } +} + +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.bootSnap == "" } +func (d *mockDevice) RunMode() bool { return d.mode == "run" } +func (d *mockDevice) HasModeenv() bool { return d.uc20 } +func (d *mockDevice) Base() string { + if d.model != nil { + return d.model.Base() + } + 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..fe75bf4a --- /dev/null +++ b/boot/boottest/device_test.go @@ -0,0 +1,111 @@ +// -*- 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.RunMode(), Equals, true) + c.Check(dev.HasModeenv(), 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.RunMode(), Equals, true) + c.Check(dev.HasModeenv(), Equals, false) + 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.RunMode(), Equals, true) + c.Check(dev.HasModeenv(), 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.RunMode(), Equals, false) + c.Check(dev.HasModeenv(), Equals, true) + c.Check(func() { dev.Model() }, PanicMatches, "Device.Model called.*") +} + +func (s *boottestSuite) TestMockUC20Device(c *C) { + dev := boottest.MockUC20Device("", nil) + c.Check(dev.HasModeenv(), Equals, true) + c.Check(dev.Classic(), Equals, false) + c.Check(dev.RunMode(), Equals, true) + c.Check(dev.Kernel(), Equals, "pc-kernel") + c.Check(dev.Base(), Equals, "core20") + + c.Check(dev.Model().Model(), Equals, "my-model-uc20") + + dev = boottest.MockUC20Device("run", nil) + c.Check(dev.RunMode(), Equals, true) + + dev = boottest.MockUC20Device("recover", nil) + c.Check(dev.RunMode(), Equals, false) + + model := boottest.MakeMockUC20Model(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 = boottest.MockUC20Device("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) +} diff --git a/boot/boottest/model.go b/boot/boottest/model.go new file mode 100644 index 00000000..0d431a5b --- /dev/null +++ b/boot/boottest/model.go @@ -0,0 +1,70 @@ +// -*- 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) +} diff --git a/boot/cmdline.go b/boot/cmdline.go new file mode 100644 index 00000000..f3edfb61 --- /dev/null +++ b/boot/cmdline.go @@ -0,0 +1,244 @@ +// -*- 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" + + "github.com/snapcore/snapd/asserts" + "github.com/snapcore/snapd/bootloader" + "github.com/snapcore/snapd/logger" + "github.com/snapcore/snapd/osutil" + "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" +) + +var ( + validModes = []string{ModeInstall, ModeRecover, ModeRun} +) + +// 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 := osutil.KernelCommandLineKeyValues("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 +} + +const ( + currentEdition = iota + candidateEdition +) + +func composeCommandLine(model *asserts.Model, currentOrCandidate int, mode, system string) (string, error) { + if model.Grade() == asserts.ModelGradeUnset { + return "", nil + } + if mode != ModeRun && mode != ModeRecover { + 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 + modeArg := "snapd_recovery_mode=run" + systemArg := "" + if mode == ModeRecover { + 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" + systemArg = fmt.Sprintf("snapd_recovery_system=%v", system) + } + mbl, err := getBootloaderManagingItsAssets(bootloaderRootDir, opts) + if err != nil { + if err == errBootConfigNotManaged { + return "", nil + } + return "", err + } + // TODO:UC20: fetch extra args from gadget + extraArgs := "" + if currentOrCandidate == currentEdition { + return mbl.CommandLine(modeArg, systemArg, extraArgs) + } else { + return mbl.CandidateCommandLine(modeArg, systemArg, extraArgs) + } +} + +// ComposeRecoveryCommandLine composes the kernel command line used when booting +// a given system in recover mode. +func ComposeRecoveryCommandLine(model *asserts.Model, system string) (string, error) { + return composeCommandLine(model, currentEdition, ModeRecover, system) +} + +// ComposeCommandLine composes the kernel command line used when booting the +// system in run mode. +func ComposeCommandLine(model *asserts.Model) (string, error) { + return composeCommandLine(model, currentEdition, ModeRun, "") +} + +// 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) (string, error) { + return composeCommandLine(model, candidateEdition, ModeRun, "") +} + +// 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 string) (string, error) { + return composeCommandLine(model, candidateEdition, ModeRecover, system) +} + +// 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: + // 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 := osutil.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) { + cmdlineExpected, err := ComposeCommandLine(model) + if err != nil { + return nil, err + } + cmdlineBootedWith, err := osutil.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 +} diff --git a/boot/cmdline_test.go b/boot/cmdline_test.go new file mode 100644 index 00000000..fc448073 --- /dev/null +++ b/boot/cmdline_test.go @@ -0,0 +1,215 @@ +// -*- 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 ( + "io/ioutil" + "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/osutil" + "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 := osutil.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 := ioutil.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: "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", + }} { + 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, "") +} diff --git a/boot/debug.go b/boot/debug.go new file mode 100644 index 00000000..bea74438 --- /dev/null +++ b/boot/debug.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 boot + +import ( + "fmt" + "io" + + "github.com/snapcore/snapd/bootloader" + "github.com/snapcore/snapd/dirs" + "github.com/snapcore/snapd/osutil" +) + +// DumpBootVars writes a dump of the snapd bootvars to the given writer +func DumpBootVars(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 ot 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", + "kernel_status", + } + } + 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 +} 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..3f4056b2 --- /dev/null +++ b/boot/export_test.go @@ -0,0 +1,191 @@ +// -*- 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/asserts" + "github.com/snapcore/snapd/bootloader" + "github.com/snapcore/snapd/secboot" + "github.com/snapcore/snapd/seed" + "github.com/snapcore/snapd/snap" + "github.com/snapcore/snapd/timings" +) + +func NewCoreBootParticipant(s snap.PlaceInfo, t snap.Type, dev Device) *coreBootParticipant { + bs, err := bootStateFor(t, dev) + if err != nil { + panic(err) + } + return &coreBootParticipant{s: s, bs: bs} +} + +func NewCoreKernel(s snap.PlaceInfo, d 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 = sealKeyToModeenv + ResealKeyToModeenv = resealKeyToModeenv + RecoveryBootChainsForSystems = recoveryBootChainsForSystems + SealKeyModelParams = sealKeyModelParams +) + +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 (o *TrustedAssetsInstallObserver) CurrentTrustedBootAssetsMap() BootAssetsMap { + return o.currentTrustedBootAssetsMap() +} + +func (o *TrustedAssetsInstallObserver) CurrentTrustedRecoveryBootAssetsMap() BootAssetsMap { + return o.currentTrustedRecoveryBootAssetsMap() +} + +func (o *TrustedAssetsInstallObserver) CurrentDataEncryptionKey() secboot.EncryptionKey { + return o.dataEncryptionKey +} + +func (o *TrustedAssetsInstallObserver) CurrentSaveEncryptionKey() secboot.EncryptionKey { + return o.saveEncryptionKey +} + +func MockSecbootSealKeys(f func(keys []secboot.SealKeyRequest, params *secboot.SealKeysParams) error) (restore func()) { + old := secbootSealKeys + secbootSealKeys = f + return func() { + secbootSealKeys = old + } +} + +func MockSecbootResealKeys(f func(params *secboot.ResealKeysParams) error) (restore func()) { + old := secbootResealKeys + secbootResealKeys = f + return func() { + secbootResealKeys = 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 (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 ( + ToPredictableBootAsset = toPredictableBootAsset + ToPredictableBootChain = toPredictableBootChain + ToPredictableBootChains = toPredictableBootChains + PredictableBootChainsEqualForReseal = predictableBootChainsEqualForReseal + BootAssetsToLoadChains = bootAssetsToLoadChains + BootAssetLess = bootAssetLess + WriteBootChains = writeBootChains + ReadBootChains = readBootChains + IsResealNeeded = isResealNeeded +) + +func (b *bootChain) SetModelAssertion(model *asserts.Model) { + b.model = model +} + +func (b *bootChain) SetKernelBootFile(kbf bootloader.BootFile) { + b.kernelBootFile = kbf +} + +func (b *bootChain) KernelBootFile() bootloader.BootFile { + return b.kernelBootFile +} + +func MockHasFDESetupHook(f func() (bool, error)) (restore func()) { + oldHasFDESetupHook := HasFDESetupHook + HasFDESetupHook = f + return func() { + HasFDESetupHook = oldHasFDESetupHook + } +} + +func MockRunFDESetupHook(f func(string, *FDESetupHookParams) ([]byte, error)) (restore func()) { + oldRunFDESetupHook := RunFDESetupHook + RunFDESetupHook = f + return func() { RunFDESetupHook = oldRunFDESetupHook } +} + +func MockResealKeyToModeenvUsingFDESetupHook(f func(string, *asserts.Model, *Modeenv, bool) error) (restore func()) { + old := resealKeyToModeenvUsingFDESetupHook + resealKeyToModeenvUsingFDESetupHook = f + return func() { + resealKeyToModeenvUsingFDESetupHook = old + } +} diff --git a/boot/initramfs.go b/boot/initramfs.go new file mode 100644 index 00000000..76cffbce --- /dev/null +++ b/boot/initramfs.go @@ -0,0 +1,121 @@ +// -*- 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/osutil" + "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, +) (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) (snap.PlaceInfo, error) + switch typ { + case snap.TypeBase: + bs := &bootState20Base{} + selectSnapFn = bs.selectAndCommitSnapInitramfsMount + 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) + 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 + } +} diff --git a/boot/initramfs20dirs.go b/boot/initramfs20dirs.go new file mode 100644 index 00000000..26d0c1a6 --- /dev/null +++ b/boot/initramfs20dirs.go @@ -0,0 +1,105 @@ +// -*- 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" +) + +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 + + // InitramfsHostWritableDir is the location of the host writable + // partition during the initramfs, typically used in recover mode. + InitramfsHostWritableDir 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 + + // 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. + InitramfsWritableDir string + + // InstallHostWritableDir is the location of the writable partition of the + // installed host during install mode. This should always be on a physical + // partition. + InstallHostWritableDir string + + // InstallHostFDEDataDir is the location of the FDE data during install mode. + InstallHostFDEDataDir 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 + + // 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 +) + +func setInitramfsDirVars(rootdir string) { + InitramfsRunMntDir = filepath.Join(rootdir, "run/mnt") + InitramfsDataDir = filepath.Join(InitramfsRunMntDir, "data") + InitramfsHostUbuntuDataDir = filepath.Join(InitramfsRunMntDir, "host", "ubuntu-data") + InitramfsHostWritableDir = filepath.Join(InitramfsHostUbuntuDataDir, "system-data") + InitramfsUbuntuBootDir = filepath.Join(InitramfsRunMntDir, "ubuntu-boot") + InitramfsUbuntuSeedDir = filepath.Join(InitramfsRunMntDir, "ubuntu-seed") + InitramfsUbuntuSaveDir = filepath.Join(InitramfsRunMntDir, "ubuntu-save") + InstallHostWritableDir = filepath.Join(InitramfsRunMntDir, "ubuntu-data", "system-data") + InstallHostFDEDataDir = dirs.SnapFDEDirUnder(InstallHostWritableDir) + InstallHostFDESaveDir = filepath.Join(InitramfsUbuntuSaveDir, "device/fde") + InitramfsWritableDir = filepath.Join(InitramfsDataDir, "system-data") + InitramfsSeedEncryptionKeyDir = filepath.Join(InitramfsUbuntuSeedDir, "device/fde") + InitramfsBootEncryptionKeyDir = filepath.Join(InitramfsUbuntuBootDir, "device/fde") +} + +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..a76ad7c4 --- /dev/null +++ b/boot/initramfs_test.go @@ -0,0 +1,632 @@ +// -*- 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" + "io/ioutil" + "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/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 = ioutil.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, comment CommentInterface, snaps ...snap.PlaceInfo) (restore func()) { + // also make sure the snaps also exist on ubuntu-data + snapDir := dirs.SnapBlobDirUnder(boot.InitramfsWritableDir) + 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 = ioutil.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) + + baseT := snap.TypeBase + kernelT := snap.TypeKernel + + 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 + comment string + expRebootPanic string + }{ + // + // 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}, + comment: "default base 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}, + comment: "default kernel path", + }, + + // + // 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}, + 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}, + 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", + 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", + 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", + comment: "fallback kernel upgrade path, due to kernel_status wrong", + }, + + // + // 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()), + 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()), + 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}, + 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}, + 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}, + 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}, + 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: "fallback base snap unusable: cannot get snap revision: modeenv base boot variable is empty", + 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()), + 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}, + 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, + }, + 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, + }, + 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, + }, + 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, + }, + 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, + }, + 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, + }, + 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) + + // 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, 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(boot.InitramfsWritableDir) + // remove it because we are writing many modeenvs in this single test + cleanups = append(cleanups, func() { + c.Assert(os.Remove(dirs.SnapModeenvFileUnder(boot.InitramfsWritableDir)), IsNil, Commentf(t.comment)) + }) + c.Assert(err, IsNil, comment) + + m, err := boot.ReadModeenv(boot.InitramfsWritableDir) + c.Assert(err, IsNil, comment) + + if t.expRebootPanic != "" { + f := func() { boot.InitramfsRunModeSelectSnapsToMount(t.typs, m) } + c.Assert(f, PanicMatches, t.expRebootPanic, comment) + } else { + mountSnaps, err := boot.InitramfsRunModeSelectSnapsToMount(t.typs, m) + 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(boot.InitramfsWritableDir) + 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() + } + } + } +} diff --git a/boot/kernel_os.go b/boot/kernel_os.go new file mode 100644 index 00000000..027c68d9 --- /dev/null +++ b/boot/kernel_os.go @@ -0,0 +1,83 @@ +// -*- 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() (rebootRequired bool, err error) { + const errPrefix = "cannot set next boot: %s" + + rebootRequired, u, err := bp.bs.setNext(bp.s) + if err != nil { + return false, fmt.Errorf(errPrefix, err) + } + + if u != nil { + if err := u.commit(); err != nil { + return false, fmt.Errorf(errPrefix, err) + } + } + return rebootRequired, 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..5dd5e118 --- /dev/null +++ b/boot/kernel_os_test.go @@ -0,0 +1,693 @@ +// -*- 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" + "io/ioutil" + "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() + c.Check(err, ErrorMatches, `cannot set next boot: zap`) + + bootloader.ForceError(errors.New("brkn")) + _, err = boot.NewCoreBootParticipant(&snap.Info{}, snap.TypeKernel, coreDev).SetNextBoot() + 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) + reboot, err := bs.SetNextBoot() + 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(reboot, Equals, 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) + reboot, err := bs.SetNextBoot() + 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(reboot, Equals, 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) + reboot, err := bp.SetNextBoot() + 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(reboot, Equals, true) + + // simulate good boot + bootVars = map[string]string{"snap_kernel": "krnl_42.snap"} + s.bootloader.SetBootVars(bootVars) + + reboot, err = bp.SetNextBoot() + c.Assert(err, IsNil) + c.Check(reboot, Equals, 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) + reboot, err := bs.SetNextBoot() + 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(reboot, Equals, true) + + // 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) + reboot, err := bs.SetNextBoot() + 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(reboot, Equals, true) + + // 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) + + reboot, err := boot.NewCoreBootParticipant(info, snap.TypeKernel, coreDev).SetNextBoot() + 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(reboot, Equals, 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) + reboot, err := bs.SetNextBoot() + 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(reboot, Equals, 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) + reboot, err := bs.SetNextBoot() + 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(reboot, Equals, 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) + + reboot, err := boot.NewCoreBootParticipant(info, snap.TypeKernel, coreDev).SetNextBoot() + 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(reboot, Equals, 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) + reboot, err := bs.SetNextBoot() + 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(reboot, Equals, 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) + reboot, err := bs.SetNextBoot() + 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(reboot, Equals, 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 := ioutil.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 := ioutil.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 := ioutil.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) +} diff --git a/boot/makebootable.go b/boot/makebootable.go new file mode 100644 index 00000000..7a8a38ff --- /dev/null +++ b/boot/makebootable.go @@ -0,0 +1,398 @@ +// -*- 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" + "os" + "path/filepath" + + "github.com/snapcore/snapd/asserts" + "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" +) + +// 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 + + RecoverySystemLabel string + RecoverySystemDir string + + UnpackedGadgetDir string + + // Recover is set when making the recovery partition bootable. + Recovery bool +} + +// MakeBootable sets up the given bootable set and target filesystem +// such that the system can be booted. +// +// rootdir points to an image filesystem (UC 16/18), image recovery +// filesystem (UC20 at prepare-image time) or ephemeral system (UC20 +// install mode). +func MakeBootable(model *asserts.Model, rootdir string, bootWith *BootableSet, sealer *TrustedAssetsInstallObserver) error { + if model.Grade() == asserts.ModelGradeUnset { + return makeBootable16(model, rootdir, bootWith) + } + + if !bootWith.Recovery { + return makeBootable20RunMode(model, rootdir, bootWith, sealer) + } + return makeBootable20(model, rootdir, bootWith) +} + +// 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 makeBootable20(model *asserts.Model, rootdir string, bootWith *BootableSet) 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, + } + + // 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) + } + + // 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 := map[string]string{ + "snapd_recovery_system": bootWith.RecoverySystemLabel, + // always set the mode as install + "snapd_recovery_mode": ModeInstall, + } + if err := bl.SetBootVars(blVars); err != nil { + return fmt.Errorf("cannot set recovery environment: %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( + bootWith.RecoverySystemDir, + 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 err := rbl.SetRecoverySystemEnv(bootWith.RecoverySystemDir, recoveryBlVars); err != nil { + return fmt.Errorf("cannot set recovery system environment: %v", err) + } + return nil +} + +func makeBootable20RunMode(model *asserts.Model, rootdir string, bootWith *BootableSet, sealer *TrustedAssetsInstallObserver) error { + // 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 into the ubuntu-data partition + snapBlobDir := dirs.SnapBlobDirUnder(InstallHostWritableDir) + 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)) + // if the source filename 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(fn) { + link, err := os.Readlink(fn) + if err != nil { + return err + } + fn = link + } + if err := osutil.CopyFile(fn, dst, osutil.CopyFlagPreserveAll|osutil.CopyFlagSync); err != nil { + return err + } + } + + // replicate the boot assets cache in host's writable + if err := CopyBootAssetsCacheToRoot(InstallHostWritableDir); 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 := filepath.Base(bootWith.RecoverySystemDir) + // write modeenv on the ubuntu-data partition + modeenv := &Modeenv{ + Mode: "run", + RecoverySystem: recoverySystemLabel, + // default to the system we were installed from + CurrentRecoverySystems: []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 + Base: filepath.Base(bootWith.BasePath), + CurrentKernels: []string{bootWith.Kernel.Filename()}, + BrandID: model.BrandID(), + Model: model.Model(), + Grade: string(model.Grade()), + } + + // 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) + } + + _, 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) + if err != nil { + return fmt.Errorf("cannot compose the candidate command line: %v", err) + } + modeenv.CurrentKernelCommandLines = bootCommandLines{cmdline} + } + + // 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); err != nil { + return fmt.Errorf("cannot write modeenv: %v", err) + } + + if sealer != nil { + // seal the encryption key to the parameters specified in modeenv + if err := sealKeyToModeenv(sealer.dataEncryptionKey, sealer.saveEncryptionKey, model, modeenv); err != nil { + return err + } + } + + // LAST step: update recovery bootloader environment to indicate that we + // transition to run mode now + opts = &bootloader.Options{ + // let the bootloader know we will be touching the recovery + // partition + Role: bootloader.RoleRecovery, + } + bl, err = bootloader.Find(InitramfsUbuntuSeedDir, opts) + if err != nil { + return fmt.Errorf("internal error: cannot find recovery system bootloader: %v", err) + } + blVars = map[string]string{ + "snapd_recovery_mode": "run", + } + if err := bl.SetBootVars(blVars); err != nil { + return fmt.Errorf("cannot set recovery environment: %v", err) + } + return nil +} diff --git a/boot/makebootable_test.go b/boot/makebootable_test.go new file mode 100644 index 00000000..0545ccc0 --- /dev/null +++ b/boot/makebootable_test.go @@ -0,0 +1,1023 @@ +// -*- 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 boot_test + +import ( + "fmt" + "io/ioutil" + "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/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/secboot" + "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/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) +} + +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) TestMakeBootable(c *C) { + bootloader.Force(nil) + model := boottest.MakeMockModel() + + grubCfg := []byte("#grub cfg") + unpackedGadgetDir := c.MkDir() + err := ioutil.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.MakeBootable(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) +} + +func (s *makeBootable20UbootSuite) SetUpTest(c *C) { + s.baseBootenvSuite.SetUpTest(c) + + s.bootloader = bootloadertest.Mock("mock", c.MkDir()).ExtractedRecoveryKernelImage() + s.forceBootloader(s.bootloader) +} + +func (s *makeBootable20Suite) TestMakeBootable20(c *C) { + bootloader.Force(nil) + model := boottest.MakeMockUC20Model() + + unpackedGadgetDir := c.MkDir() + grubRecoveryCfg := []byte("#grub-recovery cfg") + grubRecoveryCfgAsset := []byte("#grub-recovery cfg from assets") + err := ioutil.WriteFile(filepath.Join(unpackedGadgetDir, "grub-recovery.conf"), grubRecoveryCfg, 0644) + restore := assets.MockInternal("grub-recovery.cfg", grubRecoveryCfgAsset) + defer restore() + + c.Assert(err, IsNil) + grubCfg := []byte("#grub cfg") + err = ioutil.WriteFile(filepath.Join(unpackedGadgetDir, "grub.conf"), grubCfg, 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, "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.MakeBootable(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) TestMakeBootable20UnsetRecoverySystemLabelError(c *C) { + model := boottest.MakeMockUC20Model() + + unpackedGadgetDir := c.MkDir() + grubRecoveryCfg := []byte("#grub-recovery cfg") + err := ioutil.WriteFile(filepath.Join(unpackedGadgetDir, "grub-recovery.conf"), grubRecoveryCfg, 0644) + c.Assert(err, IsNil) + grubCfg := []byte("#grub cfg") + err = ioutil.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.MakeBootable(model, s.rootdir, bootWith, nil) + c.Assert(err, ErrorMatches, "internal error: recovery system label unset") +} + +func (s *makeBootable20Suite) TestMakeBootable20MultipleRecoverySystemsError(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.MakeBootable(model, s.rootdir, bootWith, nil) + c.Assert(err, ErrorMatches, "cannot make multiple recovery systems bootable yet") +} + +func (s *makeBootable20Suite) TestMakeBootable20RunMode(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 = ioutil.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 = ioutil.WriteFile(filepath.Join(boot.InitramfsUbuntuSeedDir, "EFI/boot/bootx64.efi"), + []byte("recovery shim content"), 0644) + c.Assert(err, IsNil) + // SHA3-384: aa3c1a83e74bf6dd40dd64e5c5bd1971d75cdf55515b23b9eb379f66bf43d4661d22c4b8cf7d7a982d2013ab65c1c4c5 + err = ioutil.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 = ioutil.WriteFile(mockBootGrubCfg, nil, 0644) + c.Assert(err, IsNil) + + unpackedGadgetDir := c.MkDir() + grubRecoveryCfg := []byte("#grub-recovery cfg") + grubRecoveryCfgAsset := []byte("#grub-recovery cfg from assets") + err = ioutil.WriteFile(filepath.Join(unpackedGadgetDir, "grub-recovery.conf"), grubRecoveryCfg, 0644) + c.Assert(err, IsNil) + restore := assets.MockInternal("grub-recovery.cfg", grubRecoveryCfgAsset) + defer restore() + grubCfg := []byte("#grub cfg") + err = ioutil.WriteFile(filepath.Join(unpackedGadgetDir, "grub.conf"), grubCfg, 0644) + c.Assert(err, IsNil) + grubCfgAsset := []byte("# Snapd-Boot-Config-Edition: 1\n#grub cfg from assets") + restore = assets.MockInternal("grub.cfg", grubCfgAsset) + defer restore() + + err = ioutil.WriteFile(filepath.Join(unpackedGadgetDir, "bootx64.efi"), []byte("shim content"), 0644) + c.Assert(err, IsNil) + err = ioutil.WriteFile(filepath.Join(unpackedGadgetDir, "grubx64.efi"), []byte("grub content"), 0644) + c.Assert(err, IsNil) + + // 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) + + bootWith := &boot.BootableSet{ + RecoverySystemDir: "20191216", + BasePath: baseInSeed, + Base: baseInfo, + 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) + runBootStruct := &gadget.LaidOutStructure{ + VolumeStructure: &gadget.VolumeStructure{ + Role: gadget.SystemBoot, + }, + } + + // only grubx64.efi gets installed to system-boot + _, err = obs.Observe(gadget.ContentWrite, runBootStruct, 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 := secboot.EncryptionKey{} + myKey2 := secboot.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++ + kernelSnap := &seed.Snap{ + Path: "/var/lib/snapd/seed/snaps/pc-kernel_1.snap", + SideInfo: &snap.SideInfo{ + Revision: snap.Revision{N: 1}, + RealName: "pc-kernel", + }, + } + return model, []*seed.Snap{kernelSnap}, 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) + + 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=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=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.DisplayName(), Equals, "My Model") + + return nil + }) + defer restore() + + err = boot.MakeBootable(model, s.rootdir, bootWith, obs) + c.Assert(err, IsNil) + + // ensure grub.cfg in boot was installed from internal assets + c.Check(mockBootGrubCfg, testutil.FileEquals, string(grubCfgAsset)) + + // ensure base/kernel got copied to /var/lib/snapd/snaps + core20Snap := filepath.Join(dirs.SnapBlobDirUnder(boot.InstallHostWritableDir), "core20_3.snap") + pcKernelSnap := filepath.Join(dirs.SnapBlobDirUnder(boot.InstallHostWritableDir), "pc-kernel_5.snap") + c.Check(core20Snap, 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.Check(mockSeedGrubenv, testutil.FilePresent) + c.Check(mockSeedGrubenv, testutil.FileContains, "snapd_recovery_mode=run") + 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 + 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 +base=core20_3.snap +current_kernels=pc-kernel_5.snap +model=my-brand/my-model-uc20 +grade=dangerous +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"] +`) + copiedGrubBin := filepath.Join( + dirs.SnapBootAssetsDirUnder(boot.InstallHostWritableDir), + "grub", + "grubx64.efi-5ee042c15e104b825d6bc15c41cdb026589f1ec57ed966dd3f29f961d4d6924efc54b187743fa3a583b62722882d405d", + ) + copiedRecoveryGrubBin := filepath.Join( + dirs.SnapBootAssetsDirUnder(boot.InstallHostWritableDir), + "grub", + "grubx64.efi-aa3c1a83e74bf6dd40dd64e5c5bd1971d75cdf55515b23b9eb379f66bf43d4661d22c4b8cf7d7a982d2013ab65c1c4c5", + ) + copiedRecoveryShimBin := filepath.Join( + dirs.SnapBootAssetsDirUnder(boot.InstallHostWritableDir), + "grub", + "bootx64.efi-39efae6545f16e39633fbfbef0d5e9fdd45a25d7df8764978ce4d81f255b038046a38d9855e42e5c7c4024e153fd2e37", + ) + + // only one file in the cache under new root + checkContentGlob(c, filepath.Join(dirs.SnapBootAssetsDirUnder(boot.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") + + // 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(boot.InstallHostWritableDir), "sealed-keys"), testutil.FilePresent) + + // make sure we wrote the boot chains data file + c.Check(filepath.Join(dirs.SnapFDEDirUnder(boot.InstallHostWritableDir), "boot-chains"), testutil.FilePresent) +} + +func (s *makeBootable20Suite) TestMakeBootable20RunModeInstallBootConfigErr(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 = ioutil.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) + + bootWith := &boot.BootableSet{ + RecoverySystemDir: "20191216", + BasePath: baseInSeed, + Base: baseInfo, + KernelPath: kernelInSeed, + Kernel: kernelInfo, + Recovery: false, + UnpackedGadgetDir: unpackedGadgetDir, + } + + // no grub marker in gadget directory raises an error + err = boot.MakeBootable(model, s.rootdir, 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 = ioutil.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.MakeBootable(model, s.rootdir, bootWith, nil) + c.Assert(err, ErrorMatches, `cannot install managed bootloader assets: internal error: no boot asset for "grub.cfg"`) +} + +func (s *makeBootable20Suite) TestMakeBootable20RunModeSealKeyErr(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 = ioutil.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 = ioutil.WriteFile(filepath.Join(boot.InitramfsUbuntuSeedDir, "EFI/boot/bootx64.efi"), + []byte("recovery shim content"), 0644) + c.Assert(err, IsNil) + // SHA3-384: aa3c1a83e74bf6dd40dd64e5c5bd1971d75cdf55515b23b9eb379f66bf43d4661d22c4b8cf7d7a982d2013ab65c1c4c5 + err = ioutil.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 = ioutil.WriteFile(mockBootGrubCfg, nil, 0644) + c.Assert(err, IsNil) + + unpackedGadgetDir := c.MkDir() + grubRecoveryCfg := []byte("#grub-recovery cfg") + grubRecoveryCfgAsset := []byte("#grub-recovery cfg from assets") + err = ioutil.WriteFile(filepath.Join(unpackedGadgetDir, "grub-recovery.conf"), grubRecoveryCfg, 0644) + c.Assert(err, IsNil) + restore := assets.MockInternal("grub-recovery.cfg", grubRecoveryCfgAsset) + defer restore() + grubCfg := []byte("#grub cfg") + err = ioutil.WriteFile(filepath.Join(unpackedGadgetDir, "grub.conf"), grubCfg, 0644) + c.Assert(err, IsNil) + grubCfgAsset := []byte("# Snapd-Boot-Config-Edition: 1\n#grub cfg from assets") + restore = assets.MockInternal("grub.cfg", grubCfgAsset) + defer restore() + + err = ioutil.WriteFile(filepath.Join(unpackedGadgetDir, "bootx64.efi"), []byte("shim content"), 0644) + c.Assert(err, IsNil) + err = ioutil.WriteFile(filepath.Join(unpackedGadgetDir, "grubx64.efi"), []byte("grub content"), 0644) + c.Assert(err, IsNil) + + // 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) + + bootWith := &boot.BootableSet{ + RecoverySystemDir: "20191216", + BasePath: baseInSeed, + Base: baseInfo, + 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) + runBootStruct := &gadget.LaidOutStructure{ + VolumeStructure: &gadget.VolumeStructure{ + Role: gadget.SystemBoot, + }, + } + + // only grubx64.efi gets installed to system-boot + _, err = obs.Observe(gadget.ContentWrite, runBootStruct, 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 := secboot.EncryptionKey{} + myKey2 := secboot.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++ + kernelSnap := &seed.Snap{ + Path: "/var/lib/snapd/seed/snaps/pc-kernel_1.snap", + SideInfo: &snap.SideInfo{ + Revision: snap.Revision{N: 0}, + RealName: "pc-kernel", + }, + } + return model, []*seed.Snap{kernelSnap}, 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=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.DisplayName(), Equals, "My Model") + + return fmt.Errorf("seal error") + }) + defer restore() + + err = boot.MakeBootable(model, s.rootdir, bootWith, obs) + c.Assert(err, ErrorMatches, "cannot seal the encryption keys: seal error") +} + +func (s *makeBootable20UbootSuite) TestUbootMakeBootable20TraditionalUbootenvFails(c *C) { + bootloader.Force(nil) + model := boottest.MakeMockUC20Model() + + unpackedGadgetDir := c.MkDir() + ubootEnv := []byte("#uboot env") + err := ioutil.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.MakeBootable(model, s.rootdir, bootWith, nil) + c.Assert(err, ErrorMatches, "non-empty uboot.env not supported on UC20 yet") +} + +func (s *makeBootable20UbootSuite) TestUbootMakeBootable20BootScr(c *C) { + model := boottest.MakeMockUC20Model() + + unpackedGadgetDir := c.MkDir() + // the uboot.conf must be empty for this to work/do the right thing + err := ioutil.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.MakeBootable(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) TestUbootMakeBootable20RunModeBootSel(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) + 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) + c.Assert(err, IsNil) + c.Assert(env.Save(), IsNil) + + unpackedGadgetDir := c.MkDir() + c.Assert(ioutil.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) + 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{ + RecoverySystemDir: "20191216", + BasePath: baseInSeed, + Base: baseInfo, + KernelPath: kernelInSeed, + Kernel: kernelInfo, + Recovery: false, + UnpackedGadgetDir: unpackedGadgetDir, + } + err = boot.MakeBootable(model, s.rootdir, bootWith, nil) + c.Assert(err, IsNil) + + // ensure base/kernel got copied to /var/lib/snapd/snaps + c.Check(filepath.Join(dirs.SnapBlobDirUnder(boot.InstallHostWritableDir), "core20_3.snap"), testutil.FilePresent) + c.Check(filepath.Join(dirs.SnapBlobDirUnder(boot.InstallHostWritableDir), "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") + + // 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) + + // 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 +base=core20_3.snap +current_kernels=arm-kernel_5.snap +model=my-brand/my-model-uc20 +grade=dangerous +`) +} diff --git a/boot/modeenv.go b/boot/modeenv.go new file mode 100644 index 00000000..d853c465 --- /dev/null +++ b/boot/modeenv.go @@ -0,0 +1,427 @@ +// -*- 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 ( + "bytes" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "reflect" + "sort" + "strings" + + "github.com/mvo5/goconfigparser" + + "github.com/snapcore/snapd/dirs" + "github.com/snapcore/snapd/osutil" +) + +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 []string `key:"current_recovery_systems"` + Base string `key:"base"` + TryBase string `key:"try_base"` + BaseStatus string `key:"base_status"` + CurrentKernels []string `key:"current_kernels"` + Model string `key:"model"` + BrandID string `key:"model,secondary"` + Grade string `key:"grade"` + // 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/iib/snapd/modeenv. +func ReadModeenv(rootdir string) (*Modeenv, error) { + 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, "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, "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 + // expect the caller to validate the grade + unmarshalModeenvValueFromCfg(cfg, "grade", &m.Grade) + 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 { + 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, "base", m.Base) + marshalModeenvEntryTo(buf, "try_base", m.TryBase) + marshalModeenvEntryTo(buf, "base_status", m.BaseStatus) + 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}) + } + marshalModeenvEntryTo(buf, "grade", m.Grade) + 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 +} + +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) + 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 +// th 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) + 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..486c5c42 --- /dev/null +++ b/boot/modeenv_test.go @@ -0,0 +1,725 @@ +// -*- 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_test + +import ( + "bytes" + "encoding/json" + "fmt" + "io/ioutil" + "os" + "path/filepath" + "strings" + + "github.com/mvo5/goconfigparser" + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/boot" + "github.com/snapcore/snapd/dirs" + "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, + // keep this comment to make old go fmt happy + "base": true, + "try_base": true, + "base_status": true, + "current_kernels": true, + "model": true, + "grade": 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 = ioutil.WriteFile(s.mockModeenvPath, []byte(content), 0644) + c.Assert(err, IsNil) +} + +func (s *modeenvSuite) TestWasReadSanity(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, "") +} + +func (s *modeenvSuite) TestDeepEqualDiskVsMemoryInvariant(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) + inMemoryModeenv := &boot.Modeenv{ + Mode: "recovery", + RecoverySystem: "20191126", + Base: "core20_123.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": {"hash1", "hash2"}, + "thing2": {"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 := ioutil.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"}, + + Base: "core20_123.snap", + TryBase: "core20_124.snap", + BaseStatus: "try", + CurrentKernels: []string{"k1", "k2"}, + + Model: "model", + BrandID: "brand", + Grade: "secured", + + CurrentTrustedBootAssets: boot.BootAssetsMap{ + "thing1": {"hash1", "hash2"}, + "thing2": {"hash3"}, + }, + + CurrentKernelCommandLines: boot.BootCommandLines{ + "foo", + "foo bar", + }, + } + + modeenv2 := &boot.Modeenv{ + Mode: "recovery", + RecoverySystem: "20191126", + CurrentRecoverySystems: []string{"1", "2"}, + + Base: "core20_123.snap", + TryBase: "core20_124.snap", + BaseStatus: "try", + CurrentKernels: []string{"k1", "k2"}, + + Model: "model", + BrandID: "brand", + Grade: "secured", + + CurrentTrustedBootAssets: boot.BootAssetsMap{ + "thing1": {"hash1", "hash2"}, + "thing2": {"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) +} + +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) 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, `mode=recovery +recovery_system=20191126 +current_recovery_systems=`+t.systemsString+"\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(modeenv.CurrentRecoverySystems, 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`, + }) +} diff --git a/boot/seal.go b/boot/seal.go new file mode 100644 index 00000000..c45d6477 --- /dev/null +++ b/boot/seal.go @@ -0,0 +1,669 @@ +// -*- 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/ecdsa" + "crypto/elliptic" + "crypto/rand" + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "os" + "path/filepath" + + "github.com/snapcore/snapd/asserts" + "github.com/snapcore/snapd/bootloader" + "github.com/snapcore/snapd/dirs" + "github.com/snapcore/snapd/logger" + "github.com/snapcore/snapd/osutil" + "github.com/snapcore/snapd/secboot" + "github.com/snapcore/snapd/seed" + "github.com/snapcore/snapd/snap" + "github.com/snapcore/snapd/strutil" + "github.com/snapcore/snapd/timings" +) + +var ( + secbootSealKeys = secboot.SealKeys + secbootResealKeys = secboot.ResealKeys + + 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 = func() (bool, error) { + return false, nil + } + RunFDESetupHook = func(op string, params *FDESetupHookParams) ([]byte, error) { + return nil, fmt.Errorf("internal error: RunFDESetupHook not set yet") + } +) + +type sealingMethod string + +const ( + sealingMethodLegacyTPM = sealingMethod("") + sealingMethodTPM = sealingMethod("tpm") + sealingMethodFDESetupHook = sealingMethod("fde-setup-hook") +) + +// FDESetupHookParams contains the inputs for the fde-setup hook +type FDESetupHookParams struct { + Key secboot.EncryptionKey + KeyName string + + Models []*asserts.Model + + //TODO:UC20: provide bootchains and a way to track measured + //boot-assets +} + +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") +} + +// sealKeyToModeenv seals the supplied keys to the parameters specified +// in modeenv. +// It assumes to be invoked in install mode. +func sealKeyToModeenv(key, saveKey secboot.EncryptionKey, model *asserts.Model, modeenv *Modeenv) error { + // make sure relevant locations exist + for _, p := range []string{ + InitramfsSeedEncryptionKeyDir, + InitramfsBootEncryptionKeyDir, + InstallHostFDEDataDir, + InstallHostFDESaveDir, + } { + // XXX: should that be 0700 ? + if err := os.MkdirAll(p, 0755); err != nil { + return err + } + } + + hasHook, err := HasFDESetupHook() + if err != nil { + return fmt.Errorf("cannot check for fde-setup hook %v", err) + } + if hasHook { + return sealKeyToModeenvUsingFDESetupHook(key, saveKey, model, modeenv) + } + + return sealKeyToModeenvUsingSecboot(key, saveKey, model, modeenv) +} + +func runKeySealRequests(key secboot.EncryptionKey) []secboot.SealKeyRequest { + return []secboot.SealKeyRequest{ + { + Key: key, + KeyName: "ubuntu-data", + KeyFile: filepath.Join(InitramfsBootEncryptionKeyDir, "ubuntu-data.sealed-key"), + }, + } +} + +func fallbackKeySealRequests(key, saveKey secboot.EncryptionKey) []secboot.SealKeyRequest { + return []secboot.SealKeyRequest{ + { + Key: key, + KeyName: "ubuntu-data", + KeyFile: filepath.Join(InitramfsSeedEncryptionKeyDir, "ubuntu-data.recovery.sealed-key"), + }, + { + Key: saveKey, + KeyName: "ubuntu-save", + KeyFile: filepath.Join(InitramfsSeedEncryptionKeyDir, "ubuntu-save.recovery.sealed-key"), + }, + } +} + +func sealKeyToModeenvUsingFDESetupHook(key, saveKey secboot.EncryptionKey, model *asserts.Model, modeenv *Modeenv) error { + // TODO: support full boot chains + + for _, skr := range append(runKeySealRequests(key), fallbackKeySealRequests(key, saveKey)...) { + params := &FDESetupHookParams{ + Key: skr.Key, + KeyName: skr.KeyName, + Models: []*asserts.Model{model}, + } + sealedKey, err := RunFDESetupHook("initial-setup", params) + if err != nil { + return err + } + if err := osutil.AtomicWriteFile(filepath.Join(skr.KeyFile), sealedKey, 0600, 0); err != nil { + return fmt.Errorf("cannot store key: %v", err) + } + } + + if err := stampSealedKeys(InstallHostWritableDir, "fde-setup-hook"); err != nil { + return err + } + + return nil +} + +func sealKeyToModeenvUsingSecboot(key, saveKey secboot.EncryptionKey, model *asserts.Model, modeenv *Modeenv) 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") + } + + recoveryBootChains, err := recoveryBootChainsForSystems([]string{modeenv.RecoverySystem}, tbl, model, modeenv) + 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) + } + + runModeBootChains, err := runModeBootChains(rbl, bl, model, modeenv, []string(modeenv.CurrentKernelCommandLines)) + if err != nil { + return fmt.Errorf("cannot compose run mode boot chains: %v", err) + } + + 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) + } + + if err := sealRunObjectKeys(key, pbc, authKey, roleToBlName); err != nil { + return err + } + + if err := sealFallbackObjectKeys(key, saveKey, rpbc, authKey, roleToBlName); err != nil { + return err + } + + if err := stampSealedKeys(InstallHostWritableDir, sealingMethodTPM); err != nil { + return err + } + + installBootChainsPath := bootChainsFileUnder(InstallHostWritableDir) + if err := writeBootChains(pbc, installBootChainsPath, 0); err != nil { + return err + } + + installRecoveryBootChainsPath := recoveryBootChainsFileUnder(InstallHostWritableDir) + if err := writeBootChains(rpbc, installRecoveryBootChainsPath, 0); err != nil { + return err + } + + return nil +} + +func sealRunObjectKeys(key secboot.EncryptionKey, pbc predictableBootChains, authKey *ecdsa.PrivateKey, roleToBlName map[bootloader.Role]string) 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"), + TPMLockoutAuthFile: filepath.Join(InstallHostFDESaveDir, "tpm-lockout-auth"), + TPMProvision: true, + PCRPolicyCounterHandle: secboot.RunObjectPCRPolicyCounterHandle, + } + // 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 secboot.EncryptionKey, pbc predictableBootChains, authKey *ecdsa.PrivateKey, roleToBlName map[bootloader.Role]string) 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: secboot.FallbackObjectPCRPolicyCounterHandle, + } + // 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), sealKeyParams); err != nil { + return fmt.Errorf("cannot seal the fallback encryption keys: %v", err) + } + + return nil +} + +func stampSealedKeys(rootdir string, content sealingMethod) error { + stamp := filepath.Join(dirs.SnapFDEDirUnder(rootdir), "sealed-keys") + if err := os.MkdirAll(filepath.Dir(stamp), 0755); err != nil { + return fmt.Errorf("cannot create device fde state directory: %v", err) + } + + if err := osutil.AtomicWriteFile(stamp, []byte(content), 0644, 0); err != nil { + return fmt.Errorf("cannot create fde sealed keys stamp file: %v", err) + } + return nil +} + +var errNoSealedKeys = errors.New("no sealed keys") + +// sealedKeysMethod return whether any keys were sealed at all +func sealedKeysMethod(rootdir string) (sm sealingMethod, err error) { + // TODO:UC20: consider more than the marker for cases where we reseal + // outside of run mode + stamp := filepath.Join(dirs.SnapFDEDirUnder(rootdir), "sealed-keys") + content, err := ioutil.ReadFile(stamp) + if os.IsNotExist(err) { + return sm, errNoSealedKeys + } + return sealingMethod(content), err +} + +// resealKeyToModeenv reseals the existing encryption key to the +// parameters specified in modeenv. +func resealKeyToModeenv(rootdir string, model *asserts.Model, modeenv *Modeenv, expectReseal bool) error { + method, err := sealedKeysMethod(rootdir) + if err == errNoSealedKeys { + // nothing to do + return nil + } + if err != nil { + return err + } + switch method { + case sealingMethodFDESetupHook: + return resealKeyToModeenvUsingFDESetupHook(rootdir, model, modeenv, expectReseal) + case sealingMethodTPM, sealingMethodLegacyTPM: + return resealKeyToModeenvSecboot(rootdir, model, modeenv, expectReseal) + default: + return fmt.Errorf("unknown key sealing method: %q", method) + } +} + +var resealKeyToModeenvUsingFDESetupHook = resealKeyToModeenvUsingFDESetupHookImpl + +func resealKeyToModeenvUsingFDESetupHookImpl(rootdir string, model *asserts.Model, modeenv *Modeenv, expectReseal bool) error { + // TODO: 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 +} + +func resealKeyToModeenvSecboot(rootdir string, model *asserts.Model, 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") + } + recoveryBootChains, err := recoveryBootChainsForSystems(modeenv.CurrentRecoverySystems, tbl, model, modeenv) + 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) + } + cmdline, err := ComposeCommandLine(model) + if err != nil { + return fmt.Errorf("cannot compose the run mode command line: %v", err) + } + + runModeBootChains, err := runModeBootChains(rbl, bl, model, modeenv, []string{cmdline}) + if err != nil { + return fmt.Errorf("cannot compose run mode boot chains: %v", err) + } + + // reseal the run object + pbc := toPredictableBootChains(append(runModeBootChains, recoveryBootChains...)) + + needed, nextCount, err := isResealNeeded(pbc, bootChainsFileUnder(rootdir), expectReseal) + if err != nil { + return err + } + if !needed { + logger.Debugf("reseal not necessary") + return nil + } + pbcJSON, _ := json.Marshal(pbc) + logger.Debugf("resealing (%d) to boot chains: %s", nextCount, pbcJSON) + + 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") + 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 + } + + // 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 { + logger.Debugf("fallback reseal not necessary") + return nil + } + + rpbcJSON, _ := json.Marshal(rpbc) + logger.Debugf("resealing (%d) to recovery boot chains: %s", nextCount, rpbcJSON) + + if err := resealFallbackObjectKeys(rpbc, authKeyFile, roleToBlName); err != nil { + return err + } + logger.Debugf("fallback resealing (%d) succeeded", nextFallbackCount) + + recoveryBootChainsPath := recoveryBootChainsFileUnder(rootdir) + return writeBootChains(rpbc, recoveryBootChainsPath, nextFallbackCount) +} + +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{ + filepath.Join(InitramfsBootEncryptionKeyDir, "ubuntu-data.sealed-key"), + } + + 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{ + filepath.Join(InitramfsSeedEncryptionKeyDir, "ubuntu-data.recovery.sealed-key"), + filepath.Join(InitramfsSeedEncryptionKeyDir, "ubuntu-save.recovery.sealed-key"), + } + + 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 +} + +func recoveryBootChainsForSystems(systems []string, trbl bootloader.TrustedAssetsBootloader, model *asserts.Model, modeenv *Modeenv) (chains []bootChain, err error) { + for _, system := range systems { + // get the command line + cmdline, err := ComposeRecoveryCommandLine(model, system) + if err != nil { + return nil, fmt.Errorf("cannot obtain recovery kernel command line: %v", err) + } + + // get kernel information from seed + perf := timings.New(nil) + _, snaps, err := seedReadSystemEssential(dirs.SnapSeedDir, system, []snap.Type{snap.TypeKernel}, perf) + if err != nil { + return nil, err + } + if len(snaps) != 1 { + return nil, fmt.Errorf("cannot obtain recovery kernel snap") + } + seedKernel := snaps[0] + + var kernelRev string + if seedKernel.SideInfo.Revision.Store() { + kernelRev = seedKernel.SideInfo.Revision.String() + } + + recoveryBootChain, err := trbl.RecoveryBootChain(seedKernel.Path) + if err != nil { + return nil, err + } + + // get asset chains + assetChain, kbf, err := buildBootAssets(recoveryBootChain, modeenv) + if err != nil { + return nil, err + } + + chains = append(chains, bootChain{ + BrandID: model.BrandID(), + Model: model.Model(), + Grade: model.Grade(), + ModelSignKeyID: model.SignKeyID(), + AssetChain: assetChain, + Kernel: seedKernel.SnapName(), + KernelRevision: kernelRev, + KernelCmdlines: []string{cmdline}, + model: model, + kernelBootFile: kbf, + }) + } + return chains, nil +} + +func runModeBootChains(rbl, bl bootloader.Bootloader, model *asserts.Model, modeenv *Modeenv, cmdlines []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)) + for _, k := range modeenv.CurrentKernels { + info, err := snap.ParsePlaceInfoFromSnapFileName(k) + if err != nil { + return nil, err + } + runModeBootChain, err := tbl.BootChain(bl, info.MountFile()) + if err != nil { + return nil, err + } + + // get asset chains + assetChain, kbf, err := buildBootAssets(runModeBootChain, modeenv) + if err != nil { + return nil, err + } + var kernelRev string + if info.SnapRevision().Store() { + kernelRev = info.SnapRevision().String() + } + chains = append(chains, bootChain{ + BrandID: model.BrandID(), + Model: model.Model(), + Grade: model.Grade(), + ModelSignKeyID: model.SignKeyID(), + AssetChain: assetChain, + Kernel: info.SnapName(), + KernelRevision: kernelRev, + KernelCmdlines: cmdlines, + model: model, + kernelBootFile: kbf, + }) + } + 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) (assets []bootAsset, kernel bootloader.BootFile, err error) { + 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] { + name := filepath.Base(bf.Path) + var hashes []string + var ok bool + if bf.Role == bootloader.RoleRecovery { + hashes, ok = modeenv.CurrentTrustedRecoveryBootAssets[name] + } else { + hashes, ok = modeenv.CurrentTrustedBootAssets[name] + } + if !ok { + return nil, kernel, fmt.Errorf("cannot find expected boot asset %s in modeenv", name) + } + 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) { + modelToParams := map[*asserts.Model]*secboot.SealKeyModelParams{} + modelParams := make([]*secboot.SealKeyModelParams, 0, len(pbc)) + + for _, bc := range pbc { + loadChains, err := bootAssetsToLoadChains(bc.AssetChain, bc.kernelBootFile, roleToBlName) + 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[bc.model]; ok { + params.KernelCmdlines = strutil.SortedListsUniqueMerge(params.KernelCmdlines, bc.KernelCmdlines) + params.EFILoadChains = append(params.EFILoadChains, loadChains...) + } else { + param := &secboot.SealKeyModelParams{ + Model: bc.model, + KernelCmdlines: bc.KernelCmdlines, + EFILoadChains: loadChains, + } + modelParams = append(modelParams, param) + modelToParams[bc.model] = 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 reasel 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 +} diff --git a/boot/seal_test.go b/boot/seal_test.go new file mode 100644 index 00000000..dd42b2cc --- /dev/null +++ b/boot/seal_test.go @@ -0,0 +1,1048 @@ +// -*- 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" + "io/ioutil" + "os" + "path/filepath" + "strconv" + + . "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/dirs" + "github.com/snapcore/snapd/osutil" + "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 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("/") }) +} + +func (s *sealSuite) TestSealKeyToModeenv(c *C) { + for _, tc := range []struct { + sealErr error + err string + }{ + {sealErr: nil, err: ""}, + {sealErr: errors.New("seal error"), err: "cannot seal the encryption keys: seal error"}, + } { + rootdir := c.MkDir() + dirs.SetRootDir(rootdir) + defer dirs.SetRootDir("") + + 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) + + modeenv := &boot.Modeenv{ + RecoverySystem: "20200825", + CurrentTrustedRecoveryBootAssets: boot.BootAssetsMap{ + "grubx64.efi": []string{"grub-hash-1"}, + "bootx64.efi": []string{"shim-hash-1"}, + }, + + CurrentTrustedBootAssets: boot.BootAssetsMap{ + "grubx64.efi": []string{"run-grub-hash-1"}, + }, + + CurrentKernels: []string{"pc-kernel_500.snap"}, + + CurrentKernelCommandLines: boot.BootCommandLines{ + "snapd_recovery_mode=run console=ttyS0 console=tty1 panic=-1", + }, + } + + // mock asset cache + p := filepath.Join(rootdir, "var/lib/snapd/boot-assets/grub/bootx64.efi-shim-hash-1") + err = os.MkdirAll(filepath.Dir(p), 0755) + c.Assert(err, IsNil) + err = ioutil.WriteFile(p, nil, 0644) + c.Assert(err, IsNil) + err = ioutil.WriteFile(filepath.Join(rootdir, "var/lib/snapd/boot-assets/grub/grubx64.efi-grub-hash-1"), nil, 0644) + c.Assert(err, IsNil) + err = ioutil.WriteFile(filepath.Join(rootdir, "var/lib/snapd/boot-assets/grub/grubx64.efi-run-grub-hash-1"), nil, 0644) + c.Assert(err, IsNil) + + // set encryption key + myKey := secboot.EncryptionKey{} + myKey2 := secboot.EncryptionKey{} + for i := range myKey { + myKey[i] = byte(i) + myKey2[i] = byte(128 + i) + } + + model := boottest.MakeMockUC20Model() + + // 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++ + kernelSnap := &seed.Snap{ + Path: "/var/lib/snapd/seed/snaps/pc-kernel_1.snap", + SideInfo: &snap.SideInfo{ + RealName: "pc-kernel", + Revision: snap.Revision{N: 1}, + }, + } + return model, []*seed.Snap{kernelSnap}, 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: + // the run object seals only the ubuntu-data key + c.Check(params.TPMPolicyAuthKeyFile, Equals, filepath.Join(boot.InstallHostFDESaveDir, "tpm-policy-auth-key")) + c.Check(params.TPMLockoutAuthFile, Equals, filepath.Join(boot.InstallHostFDESaveDir, "tpm-lockout-auth")) + + 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}}) + case 2: + // the fallback object seals the ubuntu-data and the ubuntu-save keys + c.Check(params.TPMPolicyAuthKeyFile, Equals, "") + c.Check(params.TPMLockoutAuthFile, 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") + c.Check(keys, DeepEquals, []secboot.SealKeyRequest{{Key: myKey, KeyName: "ubuntu-data", KeyFile: dataKeyFile}, {Key: myKey2, KeyName: "ubuntu-save", KeyFile: saveKeyFile}}) + default: + c.Errorf("unexpected additional call to secboot.SealKeys (call # %d)", sealKeysCalls) + } + c.Assert(params.ModelParams, HasLen, 1) + for _, d := range []string{boot.InitramfsSeedEncryptionKeyDir, boot.InstallHostFDEDataDir} { + 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, "var/lib/snapd/boot-assets/grub/bootx64.efi-shim-hash-1"), bootloader.RoleRecovery) + grub := bootloader.NewBootFile("", filepath.Join(rootdir, "var/lib/snapd/boot-assets/grub/grubx64.efi-grub-hash-1"), bootloader.RoleRecovery) + runGrub := bootloader.NewBootFile("", filepath.Join(rootdir, "var/lib/snapd/boot-assets/grub/grubx64.efi-run-grub-hash-1"), 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=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=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.DisplayName(), Equals, "My Model") + + return tc.sealErr + }) + defer restore() + + err = boot.SealKeyToModeenv(myKey, myKey2, model, modeenv) + if tc.sealErr != nil { + c.Assert(sealKeysCalls, Equals, 1) + } else { + c.Assert(sealKeysCalls, Equals, 2) + } + if tc.err == "" { + c.Assert(err, IsNil) + } else { + c.Assert(err, ErrorMatches, tc.err) + continue + } + + // verify the boot chains data file + pbc, cnt, err := boot.ReadBootChains(filepath.Join(dirs.SnapFDEDirUnder(boot.InstallHostWritableDir), "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: "bootx64.efi", + Hashes: []string{"shim-hash-1"}, + }, + { + Role: "recovery", + Name: "grubx64.efi", + Hashes: []string{"grub-hash-1"}, + }, + }, + Kernel: "pc-kernel", + KernelRevision: "1", + KernelCmdlines: []string{ + "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: "bootx64.efi", + Hashes: []string{"shim-hash-1"}, + }, + { + Role: "recovery", + Name: "grubx64.efi", + Hashes: []string{"grub-hash-1"}, + }, + { + Role: "run-mode", + Name: "grubx64.efi", + 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(boot.InstallHostWritableDir), "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: "bootx64.efi", + Hashes: []string{"shim-hash-1"}, + }, + { + Role: "recovery", + Name: "grubx64.efi", + Hashes: []string{"grub-hash-1"}, + }, + }, + Kernel: "pc-kernel", + KernelRevision: "1", + KernelCmdlines: []string{ + "snapd_recovery_mode=recover snapd_recovery_system=20200825 console=ttyS0 console=tty1 panic=-1", + }, + }, + }) + + // marker + marker := filepath.Join(dirs.SnapFDEDirUnder(boot.InstallHostWritableDir), "sealed-keys") + c.Check(marker, testutil.FileEquals, "tpm") + } +} + +// TODO:UC20: also test fallback reseal +func (s *sealSuite) TestResealKeyToModeenv(c *C) { + var prevPbc boot.PredictableBootChains + + for _, tc := range []struct { + sealedKeys bool + prevPbc bool + resealErr error + err string + }{ + {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"}, + {prevPbc: true, sealedKeys: true, resealErr: nil, err: ""}, + } { + rootdir := c.MkDir() + dirs.SetRootDir(rootdir) + defer dirs.SetRootDir("") + + if tc.sealedKeys { + c.Assert(os.MkdirAll(dirs.SnapFDEDir, 0755), IsNil) + err := ioutil.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) + + modeenv := &boot.Modeenv{ + CurrentRecoverySystems: []string{"20200825"}, + CurrentTrustedRecoveryBootAssets: boot.BootAssetsMap{ + "grubx64.efi": []string{"grub-hash-1"}, + "bootx64.efi": []string{"shim-hash-1", "shim-hash-2"}, + }, + + CurrentTrustedBootAssets: boot.BootAssetsMap{ + "grubx64.efi": []string{"run-grub-hash-1", "run-grub-hash-2"}, + }, + + CurrentKernels: []string{"pc-kernel_500.snap", "pc-kernel_600.snap"}, + } + + if tc.prevPbc { + err := boot.WriteBootChains(prevPbc, filepath.Join(dirs.SnapFDEDir, "boot-chains"), 9) + c.Assert(err, IsNil) + } + + // mock asset cache + p := filepath.Join(rootdir, "var/lib/snapd/boot-assets/grub/bootx64.efi-shim-hash-1") + err = os.MkdirAll(filepath.Dir(p), 0755) + c.Assert(err, IsNil) + err = ioutil.WriteFile(p, nil, 0644) + c.Assert(err, IsNil) + err = ioutil.WriteFile(filepath.Join(rootdir, "var/lib/snapd/boot-assets/grub/bootx64.efi-shim-hash-2"), nil, 0644) + c.Assert(err, IsNil) + err = ioutil.WriteFile(filepath.Join(rootdir, "var/lib/snapd/boot-assets/grub/grubx64.efi-grub-hash-1"), nil, 0644) + c.Assert(err, IsNil) + err = ioutil.WriteFile(filepath.Join(rootdir, "var/lib/snapd/boot-assets/grub/grubx64.efi-run-grub-hash-1"), nil, 0644) + c.Assert(err, IsNil) + err = ioutil.WriteFile(filepath.Join(rootdir, "var/lib/snapd/boot-assets/grub/grubx64.efi-run-grub-hash-2"), nil, 0644) + c.Assert(err, IsNil) + + model := boottest.MakeMockUC20Model() + + // 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++ + kernelSnap := &seed.Snap{ + Path: "/var/lib/snapd/seed/snaps/pc-kernel_1.snap", + SideInfo: &snap.SideInfo{ + RealName: "pc-kernel", + Revision: snap.Revision{N: 1}, + }, + } + return model, []*seed.Snap{kernelSnap}, 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.DisplayName(), Equals, "My Model") + switch resealKeysCalls { + case 1: + c.Assert(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", + }) + // load chains + c.Assert(params.ModelParams[0].EFILoadChains, HasLen, 6) + case 2: + c.Assert(params.ModelParams[0].KernelCmdlines, DeepEquals, []string{ + "snapd_recovery_mode=recover snapd_recovery_system=20200825 console=ttyS0 console=tty1 panic=-1", + }) + // load chains + c.Assert(params.ModelParams[0].EFILoadChains, HasLen, 2) + 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-1"), bootloader.RoleRecovery) + shim2 := bootloader.NewBootFile("", filepath.Join(rootdir, "var/lib/snapd/boot-assets/grub/bootx64.efi-shim-hash-2"), bootloader.RoleRecovery) + grub := bootloader.NewBootFile("", filepath.Join(rootdir, "var/lib/snapd/boot-assets/grub/grubx64.efi-grub-hash-1"), bootloader.RoleRecovery) + kernel := bootloader.NewBootFile("/var/lib/snapd/seed/snaps/pc-kernel_1.snap", "kernel.efi", bootloader.RoleRecovery) + + c.Assert(params.ModelParams[0].EFILoadChains[:2], DeepEquals, []*secboot.LoadChain{ + secboot.NewLoadChain(shim, + secboot.NewLoadChain(grub, + secboot.NewLoadChain(kernel))), + secboot.NewLoadChain(shim2, + secboot.NewLoadChain(grub, + secboot.NewLoadChain(kernel))), + }) + + // run mode parameters + runGrub := bootloader.NewBootFile("", filepath.Join(rootdir, "var/lib/snapd/boot-assets/grub/grubx64.efi-run-grub-hash-1"), bootloader.RoleRunMode) + runGrub2 := bootloader.NewBootFile("", filepath.Join(rootdir, "var/lib/snapd/boot-assets/grub/grubx64.efi-run-grub-hash-2"), 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) + + switch resealKeysCalls { + case 1: + c.Assert(params.ModelParams[0].EFILoadChains[2:4], DeepEquals, []*secboot.LoadChain{ + secboot.NewLoadChain(shim, + secboot.NewLoadChain(grub, + secboot.NewLoadChain(runGrub, + secboot.NewLoadChain(runKernel)), + secboot.NewLoadChain(runGrub2, + secboot.NewLoadChain(runKernel)), + )), + secboot.NewLoadChain(shim2, + secboot.NewLoadChain(grub, + secboot.NewLoadChain(runGrub, + secboot.NewLoadChain(runKernel)), + secboot.NewLoadChain(runGrub2, + secboot.NewLoadChain(runKernel)), + )), + }) + + c.Assert(params.ModelParams[0].EFILoadChains[4:], DeepEquals, []*secboot.LoadChain{ + secboot.NewLoadChain(shim, + secboot.NewLoadChain(grub, + secboot.NewLoadChain(runGrub, + secboot.NewLoadChain(runKernel2)), + secboot.NewLoadChain(runGrub2, + secboot.NewLoadChain(runKernel2)), + )), + secboot.NewLoadChain(shim2, + secboot.NewLoadChain(grub, + secboot.NewLoadChain(runGrub, + secboot.NewLoadChain(runKernel2)), + secboot.NewLoadChain(runGrub2, + secboot.NewLoadChain(runKernel2)), + )), + }) + } + + return tc.resealErr + }) + 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, model, modeenv, expectReseal) + if !tc.sealedKeys || tc.prevPbc { + // did nothing + c.Assert(err, IsNil) + c.Assert(resealKeysCalls, Equals, 0) + continue + } + if tc.resealErr != nil { + c.Assert(resealKeysCalls, Equals, 1) + } else { + c.Assert(resealKeysCalls, Equals, 2) + } + if tc.err == "" { + c.Assert(err, IsNil) + } else { + c.Assert(err, ErrorMatches, 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.prevPbc { + c.Assert(cnt, Equals, 10) + } else { + c.Assert(cnt, Equals, 1) + } + 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: "bootx64.efi", + Hashes: []string{"shim-hash-1", "shim-hash-2"}, + }, + { + Role: "recovery", + Name: "grubx64.efi", + Hashes: []string{"grub-hash-1"}, + }, + }, + Kernel: "pc-kernel", + KernelRevision: "1", + KernelCmdlines: []string{ + "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: "bootx64.efi", + Hashes: []string{"shim-hash-1", "shim-hash-2"}, + }, + { + Role: "recovery", + Name: "grubx64.efi", + Hashes: []string{"grub-hash-1"}, + }, + { + Role: "run-mode", + Name: "grubx64.efi", + 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: "bootx64.efi", + Hashes: []string{"shim-hash-1", "shim-hash-2"}, + }, + { + Role: "recovery", + Name: "grubx64.efi", + Hashes: []string{"grub-hash-1"}, + }, + { + Role: "run-mode", + Name: "grubx64.efi", + 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", + }, + }, + }) + prevPbc = pbc + } +} + +func (s *sealSuite) TestRecoveryBootChainsForSystems(c *C) { + for _, tc := range []struct { + assetsMap boot.BootAssetsMap + recoverySystems []string + undefinedKernel bool + expectedAssets []boot.BootAsset + expectedKernelRevs []int + err string + }{ + { + // transition sequences + recoverySystems: []string{"20200825"}, + 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}, + }, + { + // two systems + recoverySystems: []string{"20200825", "20200831"}, + 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}, + }, + { + // non-transition sequence + recoverySystems: []string{"20200825"}, + 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}, + }, + { + // invalid recovery system label + recoverySystems: []string{"0"}, + err: `invalid system seed label: "0"`, + }, + } { + rootdir := c.MkDir() + dirs.SetRootDir(rootdir) + defer dirs.SetRootDir("") + + // set recovery kernel + restore := boot.MockSeedReadSystemEssential(func(seedDir, label string, essentialTypes []snap.Type, tm timings.Measurer) (*asserts.Model, []*seed.Snap, error) { + if label != "20200825" && label != "20200831" { + return nil, nil, fmt.Errorf("invalid system seed label: %q", label) + } + kernelRev := 1 + if label == "20200831" { + kernelRev = 3 + } + kernelSnap := &seed.Snap{ + Path: fmt.Sprintf("/var/lib/snapd/seed/snaps/pc-kernel_%d.snap", kernelRev), + SideInfo: &snap.SideInfo{ + RealName: "pc-kernel", + Revision: snap.R(kernelRev), + }, + } + return nil, []*seed.Snap{kernelSnap}, 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) + + model := boottest.MakeMockUC20Model() + + modeenv := &boot.Modeenv{ + CurrentTrustedRecoveryBootAssets: tc.assetsMap, + } + + bc, err := boot.RecoveryBootChainsForSystems(tc.recoverySystems, tbl, model, modeenv) + if tc.err == "" { + c.Assert(err, IsNil) + c.Assert(bc, HasLen, len(tc.recoverySystems)) + for i, chain := range bc { + c.Assert(chain.AssetChain, DeepEquals, tc.expectedAssets) + c.Check(chain.Kernel, Equals, "pc-kernel") + expectedKernelRev := tc.expectedKernelRevs[i] + c.Check(chain.KernelRevision, Equals, fmt.Sprintf("%d", expectedKernelRev)) + c.Check(chain.KernelBootFile(), DeepEquals, bootloader.BootFile{Snap: fmt.Sprintf("/var/lib/snapd/seed/snaps/pc-kernel_%d.snap", expectedKernelRev), Path: "kernel.efi", Role: bootloader.RoleRecovery}) + } + } 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 ioutil.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 + p := filepath.Join(rootdir, "var/lib/snapd/boot-assets/grub/shim-shim-hash") + err := os.MkdirAll(filepath.Dir(p), 0755) + c.Assert(err, IsNil) + err = ioutil.WriteFile(p, nil, 0644) + c.Assert(err, IsNil) + err = ioutil.WriteFile(filepath.Join(rootdir, "var/lib/snapd/boot-assets/grub/loader-loader-hash1"), nil, 0644) + c.Assert(err, IsNil) + err = ioutil.WriteFile(filepath.Join(rootdir, "var/lib/snapd/boot-assets/grub/loader-loader-hash2"), nil, 0644) + c.Assert(err, IsNil) + + 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(), + 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"}, + } + oldrc.SetModelAssertion(oldmodel) + oldkbf := bootloader.BootFile{Snap: "pc-kernel_1.snap"} + oldrc.SetKernelBootFile(oldkbf) + + // recovery + rc1 := boot.BootChain{ + BrandID: model.BrandID(), + Model: model.Model(), + 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"}, + } + rc1.SetModelAssertion(model) + rc1kbf := bootloader.BootFile{Snap: "pc-kernel_10.snap"} + rc1.SetKernelBootFile(rc1kbf) + + // run system + runc1 := boot.BootChain{ + BrandID: model.BrandID(), + Model: model.Model(), + 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"}, + } + runc1.SetModelAssertion(model) + 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, Equals, 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, Equals, oldmodel) + 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("") + + restore := boot.MockHasFDESetupHook(func() (bool, error) { + return true, nil + }) + defer restore() + + n := 0 + var runFDESetupHookParams []*boot.FDESetupHookParams + restore = boot.MockRunFDESetupHook(func(op string, params *boot.FDESetupHookParams) ([]byte, error) { + n++ + c.Assert(op, Equals, "initial-setup") + runFDESetupHookParams = append(runFDESetupHookParams, params) + return []byte("sealed-key: " + strconv.Itoa(n)), nil + }) + defer restore() + + modeenv := &boot.Modeenv{ + RecoverySystem: "20200825", + } + key := secboot.EncryptionKey{1, 2, 3, 4} + saveKey := secboot.EncryptionKey{5, 6, 7, 8} + + model := boottest.MakeMockUC20Model() + err := boot.SealKeyToModeenv(key, saveKey, model, modeenv) + c.Assert(err, IsNil) + // check that runFDESetupHook was called the expected way + c.Check(runFDESetupHookParams, DeepEquals, []*boot.FDESetupHookParams{ + {Key: secboot.EncryptionKey{1, 2, 3, 4}, KeyName: "ubuntu-data", Models: []*asserts.Model{model}}, + {Key: secboot.EncryptionKey{1, 2, 3, 4}, KeyName: "ubuntu-data", Models: []*asserts.Model{model}}, + {Key: secboot.EncryptionKey{5, 6, 7, 8}, KeyName: "ubuntu-save", Models: []*asserts.Model{model}}, + }) + // 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"), + } { + c.Check(p, testutil.FileEquals, "sealed-key: "+strconv.Itoa(i+1)) + } + marker := filepath.Join(dirs.SnapFDEDirUnder(boot.InstallHostWritableDir), "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.MockHasFDESetupHook(func() (bool, error) { + return true, nil + }) + defer restore() + + restore = boot.MockRunFDESetupHook(func(op string, params *boot.FDESetupHookParams) ([]byte, error) { + return nil, fmt.Errorf("hook failed") + }) + defer restore() + + modeenv := &boot.Modeenv{ + RecoverySystem: "20200825", + } + key := secboot.EncryptionKey{1, 2, 3, 4} + saveKey := secboot.EncryptionKey{5, 6, 7, 8} + + model := boottest.MakeMockUC20Model() + err := boot.SealKeyToModeenv(key, saveKey, model, modeenv) + c.Assert(err, ErrorMatches, "hook failed") + marker := filepath.Join(dirs.SnapFDEDirUnder(boot.InstallHostWritableDir), "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, *asserts.Model, *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() (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 = ioutil.WriteFile(marker, []byte("fde-setup-hook"), 0644) + c.Assert(err, IsNil) + + modeenv := &boot.Modeenv{ + RecoverySystem: "20200825", + } + + model := boottest.MakeMockUC20Model() + expectReseal := false + err = boot.ResealKeyToModeenv(rootdir, model, modeenv, expectReseal) + 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, *asserts.Model, *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 = ioutil.WriteFile(marker, []byte("fde-setup-hook"), 0644) + c.Assert(err, IsNil) + + modeenv := &boot.Modeenv{ + RecoverySystem: "20200825", + } + + model := boottest.MakeMockUC20Model() + expectReseal := false + err = boot.ResealKeyToModeenv(rootdir, model, modeenv, expectReseal) + c.Assert(err, ErrorMatches, "fde setup hook failed") + c.Check(resealKeyToModeenvUsingFDESetupHookCalled, Equals, 1) +} 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..c10870d2 --- /dev/null +++ b/bootloader/asset_test.go @@ -0,0 +1,129 @@ +// -*- 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" + "io/ioutil" + "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") + ioutil.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(1)) +} + +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 := ioutil.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..b8523ef3 --- /dev/null +++ b/bootloader/assets/assets.go @@ -0,0 +1,137 @@ +// -*- 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 } + +// 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 !sort.IsSorted(byFirstEdition(snippets)) { + panic(fmt.Sprintf("edition snippets %q must be sorted in ascending edition number order", name)) + } + for i := range snippets { + if i == 0 { + continue + } + if snippets[i-1].FirstEdition == snippets[i].FirstEdition { + panic(fmt.Sprintf(`first edition %v repeated in edition snippets %q`, + snippets[i].FirstEdition, name)) + } + } + 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..d5b63007 --- /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, `edition snippets "unsorted" 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, `first edition 3 repeated in edition snippets "doubled edition"`) + // 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, `edition snippets "unsorted and doubled edition" 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/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..10ade8a2 --- /dev/null +++ b/bootloader/assets/data/grub-recovery.cfg @@ -0,0 +1,63 @@ +# Snapd-Boot-Config-Edition: 1 + +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='console=ttyS0 console=tty1 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 [ -n "$boot_fs" ]; then + menuentry "Continue to run mode" --hotkey=n --id=run { + chainloader ($boot_fs)/EFI/boot/grubx64.efi + } +fi + +# globbing in grub does not sort +for label in /systems/*; do + regexp --set 1:label "/([0-9]*)\$" "$label" + if [ -z "$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 + + # 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 $snapd_static_cmdline_args $snapd_extra_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 $snapd_static_cmdline_args $snapd_extra_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..0e5cfca4 --- /dev/null +++ b/bootloader/assets/data/grub.cfg @@ -0,0 +1,44 @@ +# Snapd-Boot-Config-Edition: 1 + +set default=0 +set timeout=3 +set timeout_style=hidden + +# load only kernel_status from the bootenv +load_env --file /EFI/ubuntu/grubenv kernel_status snapd_extra_cmdline_args + +set snapd_static_cmdline_args='console=ttyS0 console=tty1 panic=-1' + +set kernel=kernel.efi + +if [ "$kernel_status" = "try" ]; then + # a new kernel got installed + set kernel_status="trying" + save_env kernel_status + + # 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 + +if [ -e $prefix/$kernel ]; then +menuentry "Run Ubuntu Core 20" { + # 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 $snapd_static_cmdline_args $snapd_extra_cmdline_args +} +else + # nothing to boot :-/ + echo "missing kernel at $prefix/$kernel!" +fi diff --git a/bootloader/assets/export_test.go b/bootloader/assets/export_test.go new file mode 100644 index 00000000..0fd08fff --- /dev/null +++ b/bootloader/assets/export_test.go @@ -0,0 +1,36 @@ +// -*- 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 +) + +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..59ee0b09 --- /dev/null +++ b/bootloader/assets/genasset/main.go @@ -0,0 +1,157 @@ +// -*- 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) {{ .Year }} Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public 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, + 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..fb8427ec --- /dev/null +++ b/bootloader/assets/genasset/main_test.go @@ -0,0 +1,176 @@ +// -*- 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" + "io/ioutil" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + . "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 := ioutil.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 := ioutil.ReadFile(filepath.Join(d, "out")) + c.Assert(err, IsNil) + + const exp = `// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) %d Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public 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, time.Now().Year(), 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 = ioutil.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 = ioutil.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..b804fb45 --- /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 ./genasset/main.go -name grub.cfg -in ./data/grub.cfg -out ./grub_cfg_asset.go +//go:generate go run ./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..97d5528d --- /dev/null +++ b/bootloader/assets/grub.go @@ -0,0 +1,29 @@ +// -*- 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 + +func init() { + registerSnippetForEditions("grub.cfg:static-cmdline", []ForEditions{ + {FirstEdition: 1, Snippet: []byte("console=ttyS0 console=tty1 panic=-1")}, + }) + registerSnippetForEditions("grub-recovery.cfg:static-cmdline", []ForEditions{ + {FirstEdition: 1, Snippet: []byte("console=ttyS0 console=tty1 panic=-1")}, + }) +} diff --git a/bootloader/assets/grub_cfg_asset.go b/bootloader/assets/grub_cfg_asset.go new file mode 100644 index 00000000..8e973c31 --- /dev/null +++ b/bootloader/assets/grub_cfg_asset.go @@ -0,0 +1,110 @@ +// -*- 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 + +// 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, 0x31, 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, + 0x66, 0x72, 0x6f, 0x6d, 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, 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, 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, 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, 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, 0x69, 0x66, 0x20, 0x5b, 0x20, + 0x2d, 0x65, 0x20, 0x24, 0x70, 0x72, 0x65, 0x66, 0x69, 0x78, 0x2f, 0x24, 0x6b, 0x65, 0x72, 0x6e, + 0x65, 0x6c, 0x20, 0x5d, 0x3b, 0x20, 0x74, 0x68, 0x65, 0x6e, 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, 0x20, 0x32, 0x30, 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, 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, 0x0a, 0x7d, 0x0a, 0x65, + 0x6c, 0x73, 0x65, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x23, 0x20, 0x6e, 0x6f, 0x74, 0x68, 0x69, 0x6e, + 0x67, 0x20, 0x74, 0x6f, 0x20, 0x62, 0x6f, 0x6f, 0x74, 0x20, 0x3a, 0x2d, 0x2f, 0x0a, 0x20, 0x20, + 0x20, 0x20, 0x65, 0x63, 0x68, 0x6f, 0x20, 0x22, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6e, 0x67, 0x20, + 0x6b, 0x65, 0x72, 0x6e, 0x65, 0x6c, 0x20, 0x61, 0x74, 0x20, 0x24, 0x70, 0x72, 0x65, 0x66, 0x69, + 0x78, 0x2f, 0x24, 0x6b, 0x65, 0x72, 0x6e, 0x65, 0x6c, 0x21, 0x22, 0x0a, 0x66, 0x69, 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..08712408 --- /dev/null +++ b/bootloader/assets/grub_recovery_cfg_asset.go @@ -0,0 +1,157 @@ +// -*- 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 + +// 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, 0x31, 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, 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, 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, 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, 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, 0x67, 0x72, + 0x75, 0x62, 0x78, 0x36, 0x34, 0x2e, 0x65, 0x66, 0x69, 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, 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, 0x30, 0x2d, + 0x39, 0x5d, 0x2a, 0x29, 0x5c, 0x24, 0x22, 0x20, 0x22, 0x24, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x22, + 0x0a, 0x20, 0x20, 0x20, 0x20, 0x69, 0x66, 0x20, 0x5b, 0x20, 0x2d, 0x7a, 0x20, 0x22, 0x24, 0x6c, + 0x61, 0x62, 0x65, 0x6c, 0x22, 0x20, 0x5d, 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, 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, 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, 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, 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, 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..c4b3cfcd --- /dev/null +++ b/bootloader/assets/grub_test.go @@ -0,0 +1,128 @@ +// -*- 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" + "io/ioutil" + "testing" + + . "gopkg.in/check.v1" + + "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{} + +var _ = Suite(&grubAssetsTestSuite{}) + +func (s *grubAssetsTestSuite) testGrubConfigContains(c *C, name string, 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) + c.Assert(string(a[:idx]), Equals, "# Snapd-Boot-Config-Edition: 1") +} + +func (s *grubAssetsTestSuite) TestGrubConf(c *C) { + s.testGrubConfigContains(c, "grub.cfg", + "snapd_recovery_mode", + "set snapd_static_cmdline_args='console=ttyS0 console=tty1 panic=-1'", + ) +} + +func (s *grubAssetsTestSuite) TestGrubRecoveryConf(c *C) { + s.testGrubConfigContains(c, "grub-recovery.cfg", + "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) 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: 1, + content: []byte("console=ttyS0 console=tty1 panic=-1"), + pattern: "set snapd_static_cmdline_args='%s'\n", + }, + { + asset: "grub-recovery.cfg", snippet: "grub-recovery.cfg:static-cmdline", edition: 1, + 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) + 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 := ioutil.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..8edd0318 --- /dev/null +++ b/bootloader/bootloader.go @@ -0,0 +1,409 @@ +// -*- 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 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/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 + + // Role specifies to use the bootloader for the given role. + Role Role + + // 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 +} + +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 +} + +// 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, built-in bootloader specific static arguments + // corresponding to the on-disk boot asset edition, followed by any + // extra arguments. The command line may be different when using a + // recovery bootloader. + CommandLine(modeArg, systemArg, extraArgs string) (string, error) + // CandidateCommandLine is similar to CommandLine, but uses the current + // edition of managed built-in boot assets as reference. + CandidateCommandLine(modeArg, systemArg, extraArgs string) (string, error) + + // TrustedAssets returns the list of relative paths to assets inside the + // bootloader's rootdir that are measured in the boot process in the + // order of loading during the boot. Does not require rootdir to be set. + TrustedAssets() ([]string, error) + + // RecoveryBootChain returns the load chain for recovery modes. + // It should be called on a RoleRecovery bootloader. + RecoveryBootChain(kernelPath string) ([]BootFile, error) + + // BootChain returns the load chain for run mode. + // It should be called on a RoleRecovery bootloader passing the + // RoleRunMode bootloader. + BootChain(runBl Bootloader, kernelPath string) ([]BootFile, 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, + } +) + +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..63d5bbf3 --- /dev/null +++ b/bootloader/bootloader_test.go @@ -0,0 +1,362 @@ +// -*- 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" + "io/ioutil" + "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}}, + } { + mockGadgetDir := c.MkDir() + rootDir := c.MkDir() + err := ioutil.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 := ioutil.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 = ioutil.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 = ioutil.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..40996a77 --- /dev/null +++ b/bootloader/bootloadertest/bootloadertest.go @@ -0,0 +1,458 @@ +// -*- 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 + 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) + +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 + } + 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 +} + +// MockRecoveryAwareBootloader mocks a bootloader implementing the +// RecoveryAware interface. +type MockRecoveryAwareBootloader struct { + *MockBootloader + + RecoverySystemDir string + RecoverySystemBootVars map[string]string +} + +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 +} + +// 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 *MockRecoveryAwareBootloader) 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 *MockRecoveryAwareBootloader) GetRecoverySystemEnv(recoverySystemDir, key string) (string, error) { + if recoverySystemDir == "" { + panic("MockBootloader.GetRecoverySystemEnv called without recoverySystemDir") + } + b.RecoverySystemDir = recoverySystemDir + return b.RecoverySystemBootVars[key], nil +} + +// MockExtractedRunKernelImageBootloader mocks a bootloader +// implementing the ExtractedRunKernelImageBootloader interface. +type MockExtractedRunKernelImageBootloader struct { + *MockBootloader + + runKernelImageEnableKernelCalls []snap.PlaceInfo + runKernelImageEnableTryKernelCalls []snap.PlaceInfo + runKernelImageDisableTryKernelCalls []snap.PlaceInfo + runKernelImageEnabledKernel snap.PlaceInfo + runKernelImageEnabledTryKernel snap.PlaceInfo + + runKernelImageMockedErrs map[string]error + runKernelImageMockedNumCalls map[string]int +} + +// WithExtractedRunKernelImage derives a MockExtractedRunKernelImageBootloader +// from a base MockBootloader. +func (b *MockBootloader) WithExtractedRunKernelImage() *MockExtractedRunKernelImageBootloader { + return &MockExtractedRunKernelImageBootloader{ + MockBootloader: b, + + runKernelImageMockedErrs: make(map[string]error), + runKernelImageMockedNumCalls: make(map[string]int), + } +} + +// 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 *MockExtractedRunKernelImageBootloader) 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 *MockExtractedRunKernelImageBootloader) 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 *MockExtractedRunKernelImageBootloader) 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)) + } +} + +// SetRunKernelImagePanic allows setting any method in the +// ExtractedRunKernelImageBootloader interface on +// MockExtractedRunKernelImageBootloader 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 *MockExtractedRunKernelImageBootloader) SetRunKernelImagePanic(f string) (restore func()) { + switch f { + case "EnableKernel", "EnableTryKernel", "Kernel", "TryKernel", "DisableTryKernel", "SetBootVars", "GetBootVars": + old := b.panicMethods[f] + b.panicMethods[f] = true + return func() { + b.panicMethods[f] = old + } + default: + panic(fmt.Sprintf("unknown ExtractedRunKernelImageBootloader method %q to mock reboot via panic 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 *MockExtractedRunKernelImageBootloader) 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 *MockExtractedRunKernelImageBootloader) 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 *MockExtractedRunKernelImageBootloader) 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 *MockExtractedRunKernelImageBootloader) 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 *MockExtractedRunKernelImageBootloader) 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 *MockExtractedRunKernelImageBootloader) DisableTryKernel() error { + b.maybePanic("DisableTryKernel") + b.runKernelImageMockedNumCalls["DisableTryKernel"]++ + b.runKernelImageEnabledTryKernel = nil + return b.runKernelImageMockedErrs["DisableTryKernel"] +} + +// MockTrustedAssetsBootloader mocks a bootloader implementing the +// bootloader.TrustedAssetsBootloader interface. +type MockTrustedAssetsBootloader struct { + *MockBootloader + + TrustedAssetsList []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 +} + +func (b *MockBootloader) WithTrustedAssets() *MockTrustedAssetsBootloader { + return &MockTrustedAssetsBootloader{ + MockBootloader: b, + } +} + +func (b *MockTrustedAssetsBootloader) ManagedAssets() []string { + return b.ManagedAssetsList +} + +func (b *MockTrustedAssetsBootloader) UpdateBootConfig() (bool, error) { + b.UpdateCalls++ + return b.Updated, b.UpdateErr +} + +func glueCommandLine(modeArg, systemArg, staticArgs, extraArgs string) string { + args := []string(nil) + for _, argSet := range []string{modeArg, systemArg, staticArgs, extraArgs} { + if argSet != "" { + args = append(args, argSet) + } + } + line := strings.Join(args, " ") + return strings.TrimSpace(line) +} + +func (b *MockTrustedAssetsBootloader) CommandLine(modeArg, systemArg, extraArgs string) (string, error) { + if b.CommandLineErr != nil { + return "", b.CommandLineErr + } + return glueCommandLine(modeArg, systemArg, b.StaticCommandLine, extraArgs), nil +} + +func (b *MockTrustedAssetsBootloader) CandidateCommandLine(modeArg, systemArg, extraArgs string) (string, error) { + if b.CommandLineErr != nil { + return "", b.CommandLineErr + } + return glueCommandLine(modeArg, systemArg, b.CandidateStaticCommandLine, extraArgs), nil +} + +func (b *MockTrustedAssetsBootloader) TrustedAssets() ([]string, error) { + b.TrustedAssetsCalls++ + return b.TrustedAssetsList, b.TrustedAssetsErr +} + +func (b *MockTrustedAssetsBootloader) RecoveryBootChain(kernelPath string) ([]bootloader.BootFile, error) { + b.RecoveryBootChainCalls = append(b.RecoveryBootChainCalls, kernelPath) + return b.RecoveryBootChainList, b.RecoveryBootChainErr +} + +func (b *MockTrustedAssetsBootloader) BootChain(runBl bootloader.Bootloader, kernelPath string) ([]bootloader.BootFile, error) { + b.BootChainRunBl = append(b.BootChainRunBl, runBl) + b.BootChainKernelPath = append(b.BootChainKernelPath, kernelPath) + return b.BootChainList, b.BootChainErr +} 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..da89d18b --- /dev/null +++ b/bootloader/efi/efi.go @@ -0,0 +1,184 @@ +// -*- 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" + "io/ioutil" + "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 := ioutil.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 ioutil.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..8896a9fa --- /dev/null +++ b/bootloader/efi/efi_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 efi_test + +import ( + "io/ioutil" + "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 := ioutil.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 := ioutil.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 := ioutil.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 := ioutil.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..94fdfe62 --- /dev/null +++ b/bootloader/export_test.go @@ -0,0 +1,226 @@ +// -*- 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 ( + "io/ioutil" + "os" + "path/filepath" + + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/bootloader/lkenv" + "github.com/snapcore/snapd/bootloader/ubootenv" + "github.com/snapcore/snapd/osutil" + "github.com/snapcore/snapd/osutil/disks" +) + +// 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 = ioutil.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) + 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 = ioutil.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 + PartitionLabelToPartUUID: map[string]string{ + "snapbootsel": "snapbootsel-partuuid", + "snapbootselbak": "snapbootselbak-partuuid", + "snaprecoverysel": "snaprecoverysel-partuuid", + "snaprecoveryselbak": "snaprecoveryselbak-partuuid", + // for run mode kernel snaps + "boot_a": "boot-a-partuuid", + "boot_b": "boot-b-partuuid", + // for recovery system kernel snaps + "boot_ra": "boot-ra-partuuid", + "boot_rb": "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.MockDeviceNameDisksToPartitionMapping(m) + cleanups = append(cleanups, r) + + // now mock the kernel command line + cmdLine := filepath.Join(c.MkDir(), "cmdline") + ioutil.WriteFile(cmdLine, []byte("snapd_lk_boot_disk=lk-boot-disk"), 0644) + r = osutil.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 = ioutil.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(ioutil.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 = ioutil.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] + } +} + +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..223c5b60 --- /dev/null +++ b/bootloader/grub.go @@ -0,0 +1,505 @@ +// -*- 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 bootloader + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/snapcore/snapd/bootloader/assets" + "github.com/snapcore/snapd/bootloader/grubenv" + "github.com/snapcore/snapd/osutil" + "github.com/snapcore/snapd/snap" +) + +// sanity - 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 +} + +// 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 + } + 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(gadgetDir string) error { + assetName := g.Name() + "-recovery.cfg" + systemFile := filepath.Join(g.rootdir, "/EFI/ubuntu/grub.cfg") + return genericSetBootConfigFromAsset(systemFile, assetName) +} + +func (g *grub) installManagedBootConfig(gadgetDir string) 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(gadgetDir) + } + if opts != nil && opts.Role == RoleRunMode { + // install managed boot config that can handle kernel.efi + return g.installManagedBootConfig(gadgetDir) + } + + 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, modeArg, systemArg, extraArgs string) (string, error) { + assetName := "grub.cfg" + if g.recovery { + assetName = "grub-recovery.cfg" + } + staticCmdline := staticCommandLineForGrubAssetEdition(assetName, edition) + args, err := osutil.KernelCommandLineSplit(staticCmdline + " " + extraArgs) + 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 modeArg != "" { + snapdArgs = append(snapdArgs, modeArg) + } + if systemArg != "" { + snapdArgs = append(snapdArgs, systemArg) + } + return strings.Join(append(snapdArgs, args...), " "), nil +} + +// CommandLine returns the kernel command line composed of mode and +// system arguments, built-in bootloader specific static arguments +// corresponding to the on-disk boot asset edition, followed by any +// extra arguments. The command line may be different when using a +// recovery bootloader. +// +// Implements TrustedAssetsBootloader for the grub bootloader. +func (g *grub) CommandLine(modeArg, systemArg, extraArgs string) (string, error) { + currentBootConfig := filepath.Join(g.dir(), "grub.cfg") + edition, err := editionFromDiskConfigAsset(currentBootConfig) + if err != nil { + if err != errNoEdition { + return "", fmt.Errorf("cannot obtain edition number of current boot config: %v", 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 g.commandLineForEdition(edition, modeArg, systemArg, extraArgs) +} + +// 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(modeArg, systemArg, extraArgs string) (string, error) { + assetName := "grub.cfg" + if g.recovery { + assetName = "grub-recovery.cfg" + } + edition, err := editionFromInternalConfigAsset(assetName) + if err != nil { + return "", err + } + return g.commandLineForEdition(edition, modeArg, systemArg, extraArgs) +} + +// 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) +} + +var ( + grubRecoveryModeTrustedAssets = []string{ + // recovery mode shim EFI binary + "EFI/boot/bootx64.efi", + // recovery mode grub EFI binary + "EFI/boot/grubx64.efi", + } + + grubRunModeTrustedAssets = []string{ + // run mode grub EFI binary + "EFI/boot/grubx64.efi", + } +) + +// TrustedAssets returns the list of relative paths to assets inside +// the bootloader's rootdir that are measured in the boot process in the +// order of loading during the boot. +func (g *grub) TrustedAssets() ([]string, error) { + if !g.nativePartitionLayout { + return nil, fmt.Errorf("internal error: trusted assets called without native host-partition layout") + } + if g.recovery { + return grubRecoveryModeTrustedAssets, nil + } + return grubRunModeTrustedAssets, nil +} + +// RecoveryBootChain returns the load chain for recovery modes. +// It should be called on a RoleRecovery bootloader. +func (g *grub) RecoveryBootChain(kernelPath string) ([]BootFile, error) { + if !g.recovery { + return nil, fmt.Errorf("not a recovery bootloader") + } + + // add trusted assets to the recovery chain + chain := make([]BootFile, 0, len(grubRecoveryModeTrustedAssets)+1) + for _, ta := range grubRecoveryModeTrustedAssets { + chain = append(chain, NewBootFile("", ta, RoleRecovery)) + } + // add recovery kernel to the recovery chain + chain = append(chain, NewBootFile(kernelPath, "kernel.efi", RoleRecovery)) + + return chain, nil +} + +// BootChain returns the load chain for run mode. +// It should be called on a RoleRecovery bootloader passing the +// RoleRunMode bootloader. +func (g *grub) BootChain(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 + chain := make([]BootFile, 0, len(grubRecoveryModeTrustedAssets)+len(grubRunModeTrustedAssets)+1) + for _, ta := range grubRecoveryModeTrustedAssets { + chain = append(chain, NewBootFile("", ta, RoleRecovery)) + } + for _, ta := range grubRunModeTrustedAssets { + chain = append(chain, NewBootFile("", ta, RoleRunMode)) + } + // add kernel to the boot chain + chain = append(chain, NewBootFile(kernelPath, "kernel.efi", RoleRunMode)) + + return chain, nil +} diff --git a/bootloader/grub_test.go b/bootloader/grub_test.go new file mode 100644 index 00000000..a8d1eee1 --- /dev/null +++ b/bootloader/grub_test.go @@ -0,0 +1,1096 @@ +// -*- 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 ( + "fmt" + "io/ioutil" + "os" + "os/exec" + "path/filepath" + + "github.com/mvo5/goconfigparser" + . "gopkg.in/check.v1" + + "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") +} + +// 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 = ioutil.WriteFile(filepath.Join(s.grubEFINativeDir(), "grub.cfg"), content, 0644) + 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 = ioutil.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 = ioutil.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) 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("snapd_recovery_mode=run", "", "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("snapd_recovery_mode=recover", "snapd_recovery_system=1234", "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("snapd_recovery_mode=run", "", 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("", "", 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("snapd_recovery_mode=recover", "snapd_recovery_system=20200202", 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("snapd_recovery_mode=run", "", extraArgs) + c.Assert(err, IsNil) + c.Check(args, Equals, `snapd_recovery_mode=run edition=3 static args extra_arg=1`) +} + +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("snapd_recovery_mode=run", "", "extra=1") + c.Assert(err, IsNil) + c.Check(args, Equals, `snapd_recovery_mode=run edition=1 extra=1`) + args, err = recoverymg.CandidateCommandLine("snapd_recovery_mode=recover", "snapd_recovery_system=20200202", "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("snapd_recovery_mode=run", "", "extra=1") + c.Assert(err, IsNil) + c.Check(args, Equals, `snapd_recovery_mode=run edition=3 extra=1`) + args, err = recoverymg.CandidateCommandLine("snapd_recovery_mode=recover", "snapd_recovery_system=20200202", "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("snapd_recovery_mode=run", "", "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("snapd_recovery_mode=recover", "snapd_recovery_system=20200202", "extra=1") + c.Assert(err, IsNil) + c.Check(args, Equals, `snapd_recovery_mode=recover snapd_recovery_system=20200202 recovery edition=4up extra=1`) +} + +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("snapd_recovery_mode=run", "", extraArgs) + c.Assert(err, IsNil) + c.Check(args, Equals, `snapd_recovery_mode=run console=ttyS0 console=tty1 panic=-1 foo bar baz=1`) + + // 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("snapd_recovery_mode=recover", "snapd_recovery_system=20200202", 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`) +} + +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, []string{ + "EFI/boot/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, []string{ + "EFI/boot/bootx64.efi", + "EFI/boot/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) 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) + + chain, err := tab.RecoveryBootChain("kernel.snap") + c.Assert(err, IsNil) + c.Assert(chain, 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.RecoveryBootChain("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}) + + chain, err := tab.BootChain(g2, "kernel.snap") + c.Assert(err, IsNil) + c.Assert(chain, 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) 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.BootChain(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..547d3bea --- /dev/null +++ b/bootloader/grubenv/grubenv.go @@ -0,0 +1,117 @@ +// -*- 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" + "io/ioutil" + "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 := ioutil.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..81c7929d --- /dev/null +++ b/bootloader/lk.go @@ -0,0 +1,515 @@ +// -*- 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 + +import ( + "fmt" + "io" + "io/ioutil" + "os" + "path/filepath" + + "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/snap" + "golang.org/x/xerrors" +) + +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 := osutil.KernelCommandLineKeyValues("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: however this codepath will likely be exercised when we + // support creating new recovery systems from runtime + return fmt.Errorf("internal error: ExtractRecoveryKernelAssets not yet implemented 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 := ioutil.TempDir("", "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..4158444b --- /dev/null +++ b/bootloader/lk_test.go @@ -0,0 +1,500 @@ +// -*- 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" + "io/ioutil" + "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 = ioutil.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 := ioutil.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 := ioutil.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 = ioutil.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 := ioutil.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 = ioutil.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, "") +} + +// 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..aecfb9fc --- /dev/null +++ b/bootloader/lkenv/lkenv.go @@ -0,0 +1,673 @@ +// -*- 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. + +// bootimgKernelMatrix is essentially a map of boot image partition label to +// kernel revision, but implemented as a matrix of byte arrays, where the first +// row of the matrix is the boot image partition label and the second row is the +// corresponding kernel revision (for a given column). +type bootimgKernelMatrix [SNAP_BOOTIMG_PART_NUM][2][SNAP_FILE_NAME_MAX_LEN]byte + +// bootimgRecoverySystemMatrix is the same idea as bootimgKernelMatrix, but +// instead of mapping boot image partition labels to kernel revisions, it maps +// to recovery system labels for UC20. +type bootimgRecoverySystemMatrix [SNAP_RECOVERY_BOOTIMG_PART_NUM][2][SNAP_FILE_NAME_MAX_LEN]byte + +// 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..f66c513c --- /dev/null +++ b/bootloader/lkenv/lkenv_test.go @@ -0,0 +1,911 @@ +// -*- 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" + "io/ioutil" + "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 { + envPath string + envPathbak string +} + +var _ = Suite(&lkenvTestSuite{}) + +var ( + lkversions = []lkenv.Version{ + lkenv.V1, + lkenv.V2Run, + lkenv.V2Recovery, + } +) + +func (l *lkenvTestSuite) SetUpTest(c *C) { + l.envPath = filepath.Join(c.MkDir(), "snapbootsel.bin") + l.envPathbak = l.envPath + "bak" +} + +// unpack test data packed with gzip +func unpackTestData(data []byte) (resData []byte, err error) { + b := bytes.NewBuffer(data) + var r io.Reader + r, err = gzip.NewReader(b) + if err != nil { + return + } + var env bytes.Buffer + _, err = env.ReadFrom(r) + if err != nil { + return + } + 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 cutof + {[]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", + }, + "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 := ioutil.WriteFile(testFileBackup, buf, 0644) + c.Assert(err, IsNil, comment) + } + + buf := make([]byte, 4096) + err := ioutil.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 = ioutil.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 := ioutil.WriteFile(testFile, nil, 0644) + c.Assert(err, IsNil) + err = ioutil.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 := ioutil.WriteFile(testFile, nil, 0644) + c.Assert(err, IsNil) + err = ioutil.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 = ioutil.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 := ioutil.WriteFile(testFileBackup, buf, 0644) + c.Assert(err, IsNil) + } + + buf := make([]byte, 100000) + err := ioutil.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 := ioutil.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 = ioutil.WriteFile(l.envPath, rawData, 0644) + c.Assert(err, IsNil) + err = ioutil.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..d24f154e --- /dev/null +++ b/bootloader/lkenv/lkenv_v1.go @@ -0,0 +1,218 @@ +// -*- 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..a0a156ea --- /dev/null +++ b/bootloader/lkenv/lkenv_v2.go @@ -0,0 +1,334 @@ +// -*- 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 + + /* 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 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[:]) + } + 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) + } +} + +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/uboot.go b/bootloader/uboot.go new file mode 100644 index 00000000..2535d8ff --- /dev/null +++ b/bootloader/uboot.go @@ -0,0 +1,199 @@ +// -*- 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 + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/snapcore/snapd/bootloader/ubootenv" + "github.com/snapcore/snapd/osutil" + "github.com/snapcore/snapd/snap" +) + +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) 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) + 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..4d88d552 --- /dev/null +++ b/bootloader/uboot_test.go @@ -0,0 +1,272 @@ +// -*- 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) + 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") + } +} diff --git a/bootloader/ubootenv/env.go b/bootloader/ubootenv/env.go new file mode 100644 index 00000000..16c78749 --- /dev/null +++ b/bootloader/ubootenv/env.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 ubootenv + +import ( + "bufio" + "bytes" + "encoding/binary" + "fmt" + "hash/crc32" + "io" + "io/ioutil" + "os" + "path/filepath" + "sort" + "strings" +) + +// FIXME: add config option for that so that the user can select if +// he/she wants env with or without flags +var headerSize = 5 + +// Env contains the data of the uboot environment +type Env struct { + fname string + size int + 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() +} + +// Create a new empty uboot env file with the given size +func Create(fname string, size int) (*Env, error) { + f, err := os.Create(fname) + if err != nil { + return nil, err + } + defer f.Close() + + env := &Env{ + fname: fname, + size: size, + 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 := ioutil.ReadAll(f) + if err != nil { + return nil, err + } + + if len(contentWithHeader) < headerSize { + return nil, fmt.Errorf("cannot open %q: smaller than expected header", fname) + } + + crc := readUint32(contentWithHeader) + + payload := contentWithHeader[headerSize:] + actualCRC := crc32.ChecksumIEEE(payload) + if crc != actualCRC { + return nil, fmt.Errorf("cannot open %q: bad CRC %v != %v", fname, 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{ + fname: fname, + size: len(contentWithHeader), + 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 +} + +// 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 { + 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..6d473cd0 --- /dev/null +++ b/bootloader/ubootenv/env_test.go @@ -0,0 +1,308 @@ +// -*- 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/ioutil" + "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) + 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) + 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 := ioutil.WriteFile(empty, nil, 0644) + c.Assert(err, IsNil) + + _, err = ubootenv.Open(empty) + c.Assert(err, ErrorMatches, `cannot open ".*": smaller than expected header`) +} + +func (u *uenvTestSuite) TestOpenEnvBadCRC(c *C) { + corrupted := filepath.Join(c.MkDir(), "corrupted.env") + + buf := make([]byte, 4096) + err := ioutil.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) + 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) + 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) + 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) + 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) + 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) { + w := bytes.NewBuffer(nil) + crc := crc32.ChecksumIEEE(mockData) + w.Write(ubootenv.WriteUint32(crc)) + 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) + + env, err := ubootenv.Open(u.envFile) + c.Assert(err, IsNil) + c.Assert(env.String(), Equals, "foo=bar\n") +} + +// 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) + + env, err := ubootenv.Open(u.envFile) + c.Assert(err, ErrorMatches, `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) + + 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)) + } +} + +func (u *uenvTestSuite) TestErrorOnMissingKeyInKeyValuePair(c *C) { + mockData := []byte{ + // =foo + 0x3d, 0x66, 0x6f, 0x6f, + // eof + 0x00, 0x00, + } + u.makeUbootEnvFromData(c, mockData) + + env, err := ubootenv.Open(u.envFile) + c.Assert(err, ErrorMatches, `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) + + env, err := ubootenv.Open(u.envFile) + c.Assert(err, IsNil) + c.Assert(env.String(), Equals, "") +} + +func (u *uenvTestSuite) TestWritesEmptyFileWithDoubleNewline(c *C) { + env, err := ubootenv.Create(u.envFile, 12) + 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 := ioutil.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, "") +} + +func (u *uenvTestSuite) TestWritesContentCorrectly(c *C) { + totalSize := 16 + + env, err := ubootenv.Create(u.envFile, totalSize) + 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 := ioutil.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) +} 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/build-aux/snap/snapcraft.yaml b/build-aux/snap/snapcraft.yaml new file mode 100644 index 00000000..e5ae0c82 --- /dev/null +++ b/build-aux/snap/snapcraft.yaml @@ -0,0 +1,129 @@ +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 +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.10/stable] + override-pull: | + snapcraftctl pull + # 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 + # set version after installing dependencies so we have all the tools here + snapcraftctl set-version "$(./mkversion.sh --output-only)" + 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 + dpkg-deb -x $(pwd)/../snapd_*.deb $SNAPCRAFT_PART_INSTALL + + # 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/* + # libc6 is part of core but we need it in the snapd snap for + # CommandFromSystemSnap + libc6: + plugin: nil + stage-packages: + - libc6 + - libc-bin + 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 + # the version in Ubuntu 16.04 (cache v6) + fontconfig-xenial: + plugin: nil + build-packages: [python3-apt] + source: https://github.com/snapcore/fc-cache-static-builder.git + override-build: | + ./build-from-security.py xenial + mkdir -p $SNAPCRAFT_PART_INSTALL/bin + cp -a fc-cache-xenial $SNAPCRAFT_PART_INSTALL/bin/fc-cache-v6 + prime: + - bin/fc-cache-v6 + # the version in Ubuntu 18.04 (cache v7) + fontconfig-bionic: + plugin: nil + build-packages: [python3-apt] + source: https://github.com/snapcore/fc-cache-static-builder.git + override-build: | + ./build-from-security.py bionic + mkdir -p $SNAPCRAFT_PART_INSTALL/bin + cp -a fc-cache-bionic $SNAPCRAFT_PART_INSTALL/bin/fc-cache-v7 + prime: + - bin/fc-cache-v7 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..6d7b02b0 --- /dev/null +++ b/client/apps.go @@ -0,0 +1,264 @@ +// -*- 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" + "strconv" + "strings" + "time" +) + +// 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"` + 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 +} + +// 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") + } + + 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 +} + +func (l Log) String() string { + return fmt.Sprintf("%s %s[%s]: %s", l.Timestamp.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 +} + +// 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"` + 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, opts StartOptions) (changeID string, err error) { + if len(names) == 0 { + return "", ErrNoNames + } + + buf, err := json.Marshal(appInstruction{ + Action: "start", + Names: names, + 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, opts StopOptions) (changeID string, err error) { + if len(names) == 0 { + return "", ErrNoNames + } + + buf, err := json.Marshal(appInstruction{ + Action: "stop", + Names: names, + 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, opts RestartOptions) (changeID string, err error) { + if len(names) == 0 { + return "", ErrNoNames + } + + buf, err := json.Marshal(appInstruction{ + Action: "restart", + Names: names, + 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..a2b8dacb --- /dev/null +++ b/client/apps_test.go @@ -0,0 +1,404 @@ +// -*- 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" + "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 +} + +var appcheckers = []func(*clientSuite, *check.C) ([]*client.AppInfo, error){testClientApps, testClientAppsService} + +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) TestClientServiceStart(c *check.C) { + cs.status = 202 + cs.rsp = `{"type": "async", "status-code": 202, "change": "24"}` + + type scenario struct { + names []string + opts client.StartOptions + comment check.CommentInterface + } + + var scenarios []scenario + + for _, names := range [][]string{ + nil, {}, + {"foo"}, + {"foo", "bar", "baz"}, + } { + for _, opts := range []client.StartOptions{ + {Enable: true}, + {Enable: false}, + } { + scenarios = append(scenarios, scenario{ + names: names, + opts: opts, + comment: check.Commentf("{%q; %#v}", names, opts), + }) + } + } + + for _, sc := range scenarios { + id, err := cs.cli.Start(sc.names, sc.opts) + if len(sc.names) == 0 { + c.Check(id, check.Equals, "", sc.comment) + c.Check(err, check.Equals, client.ErrNoNames, sc.comment) + c.Check(cs.req, check.IsNil, sc.comment) // i.e. the request was never done + } else { + c.Assert(err, check.IsNil, sc.comment) + c.Check(id, check.Equals, "24", sc.comment) + c.Check(cs.req.URL.Path, check.Equals, "/v2/apps", sc.comment) + c.Check(cs.req.Method, check.Equals, "POST", sc.comment) + c.Check(cs.req.URL.Query(), check.HasLen, 0, sc.comment) + + inames := make([]interface{}, len(sc.names)) + for i, name := range sc.names { + inames[i] = interface{}(name) + } + + var reqOp map[string]interface{} + c.Assert(json.NewDecoder(cs.req.Body).Decode(&reqOp), check.IsNil, sc.comment) + if sc.opts.Enable { + c.Check(len(reqOp), check.Equals, 3, sc.comment) + c.Check(reqOp["enable"], check.Equals, true, sc.comment) + } else { + c.Check(len(reqOp), check.Equals, 2, sc.comment) + c.Check(reqOp["enable"], check.IsNil, sc.comment) + } + c.Check(reqOp["action"], check.Equals, "start", sc.comment) + c.Check(reqOp["names"], check.DeepEquals, inames, sc.comment) + } + } +} + +func (cs *clientSuite) TestClientServiceStop(c *check.C) { + cs.status = 202 + cs.rsp = `{"type": "async", "status-code": 202, "change": "24"}` + + type tT struct { + names []string + opts client.StopOptions + comment check.CommentInterface + } + + var scs []tT + + for _, names := range [][]string{ + nil, {}, + {"foo"}, + {"foo", "bar", "baz"}, + } { + for _, opts := range []client.StopOptions{ + {Disable: true}, + {Disable: false}, + } { + scs = append(scs, tT{ + names: names, + opts: opts, + comment: check.Commentf("{%q; %#v}", names, opts), + }) + } + } + + for _, sc := range scs { + id, err := cs.cli.Stop(sc.names, sc.opts) + if len(sc.names) == 0 { + c.Check(id, check.Equals, "", sc.comment) + c.Check(err, check.Equals, client.ErrNoNames, sc.comment) + c.Check(cs.req, check.IsNil, sc.comment) // i.e. the request was never done + } else { + c.Assert(err, check.IsNil, sc.comment) + c.Check(id, check.Equals, "24", sc.comment) + c.Check(cs.req.URL.Path, check.Equals, "/v2/apps", sc.comment) + c.Check(cs.req.Method, check.Equals, "POST", sc.comment) + c.Check(cs.req.URL.Query(), check.HasLen, 0, sc.comment) + + inames := make([]interface{}, len(sc.names)) + for i, name := range sc.names { + inames[i] = interface{}(name) + } + + var reqOp map[string]interface{} + c.Assert(json.NewDecoder(cs.req.Body).Decode(&reqOp), check.IsNil, sc.comment) + if sc.opts.Disable { + c.Check(len(reqOp), check.Equals, 3, sc.comment) + c.Check(reqOp["disable"], check.Equals, true, sc.comment) + } else { + c.Check(len(reqOp), check.Equals, 2, sc.comment) + c.Check(reqOp["disable"], check.IsNil, sc.comment) + } + c.Check(reqOp["action"], check.Equals, "stop", sc.comment) + c.Check(reqOp["names"], check.DeepEquals, inames, sc.comment) + } + } +} + +func (cs *clientSuite) TestClientServiceRestart(c *check.C) { + cs.status = 202 + cs.rsp = `{"type": "async", "status-code": 202, "change": "24"}` + + type tT struct { + names []string + opts client.RestartOptions + comment check.CommentInterface + } + + var scs []tT + + for _, names := range [][]string{ + nil, {}, + {"foo"}, + {"foo", "bar", "baz"}, + } { + for _, opts := range []client.RestartOptions{ + {Reload: true}, + {Reload: false}, + } { + scs = append(scs, tT{ + names: names, + opts: opts, + comment: check.Commentf("{%q; %#v}", names, opts), + }) + } + } + + for _, sc := range scs { + id, err := cs.cli.Restart(sc.names, sc.opts) + if len(sc.names) == 0 { + c.Check(id, check.Equals, "", sc.comment) + c.Check(err, check.Equals, client.ErrNoNames, sc.comment) + c.Check(cs.req, check.IsNil, sc.comment) // i.e. the request was never done + } else { + c.Assert(err, check.IsNil, sc.comment) + c.Check(id, check.Equals, "24", sc.comment) + c.Check(cs.req.URL.Path, check.Equals, "/v2/apps", sc.comment) + c.Check(cs.req.Method, check.Equals, "POST", sc.comment) + c.Check(cs.req.URL.Query(), check.HasLen, 0, sc.comment) + + inames := make([]interface{}, len(sc.names)) + for i, name := range sc.names { + inames[i] = interface{}(name) + } + + var reqOp map[string]interface{} + c.Assert(json.NewDecoder(cs.req.Body).Decode(&reqOp), check.IsNil, sc.comment) + if sc.opts.Reload { + c.Check(len(reqOp), check.Equals, 3, sc.comment) + c.Check(reqOp["reload"], check.Equals, true, sc.comment) + } else { + c.Check(len(reqOp), check.Equals, 2, sc.comment) + c.Check(reqOp["reload"], check.IsNil, sc.comment) + } + c.Check(reqOp["action"], check.Equals, "restart", sc.comment) + c.Check(reqOp["names"], check.DeepEquals, inames, sc.comment) + } + } +} diff --git a/client/asserts.go b/client/asserts.go new file mode 100644 index 00000000..f1545a96 --- /dev/null +++ b/client/asserts.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 client + +import ( + "bytes" + "context" + "fmt" + "io" + "net/url" + "strconv" + + "golang.org/x/xerrors" + + "github.com/snapcore/snapd/asserts" // for parsing + "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) + } + + sanityCount, 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) != sanityCount { + 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..d0c00ca0 --- /dev/null +++ b/client/asserts_test.go @@ -0,0 +1,239 @@ +// -*- 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/ioutil" + "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 := ioutil.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..1cba584a --- /dev/null +++ b/client/change_test.go @@ -0,0 +1,233 @@ +// -*- 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 ( + "gopkg.in/check.v1" + + "github.com/snapcore/snapd/client" + "io/ioutil" + "time" +) + +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 := ioutil.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..70adf1ac --- /dev/null +++ b/client/client.go @@ -0,0 +1,749 @@ +// -*- 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" + "context" + "encoding/json" + "fmt" + "io" + "io/ioutil" + "net" + "net/http" + "net/url" + "os" + "path" + "strconv" + "time" + + "github.com/snapcore/snapd/dirs" + "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 +} + +// New returns a new instance of Client +func New(config *Config) *Client { + if config == nil { + config = &Config{} + } + + // By default talk over an UNIX socket. + if config.BaseURL == "" { + transport := &http.Transport{Dial: unixDialer(config.Socket), DisableKeepAlives: config.DisableKeepAlive} + return &Client{ + baseURL: url.URL{ + Scheme: "http", + Host: "localhost", + }, + doer: &http.Client{Transport: transport}, + disableAuth: config.DisableAuth, + interactive: config.Interactive, + userAgent: config.UserAgent, + } + } + + baseURL, err := url.Parse(config.BaseURL) + if err != nil { + panic(fmt.Sprintf("cannot parse server base URL: %q (%v)", config.BaseURL, err)) + } + return &Client{ + baseURL: *baseURL, + doer: &http.Client{Transport: &http.Transport{DisableKeepAlives: config.DisableKeepAlive}}, + disableAuth: config.DisableAuth, + interactive: config.Interactive, + userAgent: config.UserAgent, + } +} + +// 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{ error } + +func (e AuthorizationError) Error() string { + return fmt.Sprintf("cannot add authorization: %v", e.error) +} + +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 +} + +// 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, fmt.Errorf("internal error: 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 + var ctx context.Context = 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, fmt.Errorf("internal error: 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 || 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 decodeInto(reader io.Reader, v interface{}) error { + dec := json.NewDecoder(reader) + if err := dec.Decode(v); err != nil { + r := dec.Buffered() + buf, err1 := ioutil.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"` +} + +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"` +} + +func (client *Client) SystemRecoveryKeys(result interface{}) error { + _, err := client.doSync("GET", "/v2/system-recovery-keys", nil, nil, nil, &result) + return err +} diff --git a/client/client_test.go b/client/client_test.go new file mode 100644 index 00000000..1c558288 --- /dev/null +++ b/client/client_test.go @@ -0,0 +1,643 @@ +// -*- 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" + "io" + "io/ioutil" + "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/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 := ioutil.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(ioutil.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 := ioutil.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 := ioutil.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 := ioutil.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 > 500, Equals, true) + c.Assert(cs.doCalls < 1100, Equals, true) +} + +func (cs *clientSuite) TestClientUnderstandsStatusCode(c *C) { + var v []int + cs.status = 202 + cs.rsp = `[1,2]` + reqBody := ioutil.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(ioutil.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"]}}}` + 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", + }) +} + +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: ioutil.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: ioutil.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 := ioutil.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 := ioutil.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"}}) +} + +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") +} diff --git a/client/clientutil/snapinfo.go b/client/clientutil/snapinfo.go new file mode 100644 index 00000000..e2decd52 --- /dev/null +++ b/client/clientutil/snapinfo.go @@ -0,0 +1,149 @@ +// -*- 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, + Contact: snapInfo.Contact, + Title: snapInfo.Title(), + License: snapInfo.License, + Media: snapInfo.Media, + Prices: snapInfo.Prices, + Channels: snapInfo.Channels, + Tracks: snapInfo.Tracks, + CommonIDs: snapInfo.CommonIDs, + Website: snapInfo.Website, + StoreURL: snapInfo.StoreURL, + } + + return result, err +} + +func ClientAppInfoNotes(app *client.AppInfo) string { + if !app.IsService() { + return "-" + } + + var notes = make([]string, 0, 2) + var seenTimer, seenSocket bool + for _, act := range app.Activators { + switch act.Type { + case "timer": + seenTimer = true + case "socket": + seenSocket = true + } + } + if seenTimer { + notes = append(notes, "timer-activated") + } + if seenSocket { + notes = append(notes, "socket-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 + 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..3213da1a --- /dev/null +++ b/client/clientutil/snapinfo_test.go @@ -0,0 +1,286 @@ +// -*- 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 ( + "io/ioutil" + "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", + Contact: "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"}, + Website: "http://example.com/thingy", + StoreURL: "https://snapcraft.io/thingy", + Broken: "broken", + } + // 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", + } + 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.Website, Equals, si.Website) + c.Check(ci.StoreURL, Equals, si.StoreURL) + c.Check(ci.Developer, Equals, "thingyinc") + c.Check(ci.Publisher, DeepEquals, &si.Publisher) +} + +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"}, + "app": {Snap: si, Name: "app", CommonID: "common.id"}, + } + // sanity + 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 = ioutil.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"}, + }) + // 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"}, + } + // 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", 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: "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") + + // check that the output is stable regardless of the order of activators + ai = client.AppInfo{ + Daemon: "oneshot", + Activators: []client.AppActivator{ + {Type: "timer"}, + {Type: "socket"}, + }, + } + c.Check(clientutil.ClientAppInfoNotes(&ai), Equals, "timer-activated,socket-activated") + ai = client.AppInfo{ + Daemon: "oneshot", + Activators: []client.AppActivator{ + {Type: "socket"}, + {Type: "timer"}, + }, + } + c.Check(clientutil.ClientAppInfoNotes(&ai), Equals, "timer-activated,socket-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..62c46714 --- /dev/null +++ b/client/cohort_test.go @@ -0,0 +1,77 @@ +// -*- 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/ioutil" + + "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 := ioutil.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 := ioutil.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..f26a468a --- /dev/null +++ b/client/errors.go @@ -0,0 +1,148 @@ +// -*- 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" + + // 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..16bac52e --- /dev/null +++ b/client/export_test.go @@ -0,0 +1,55 @@ +// -*- 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 diff --git a/client/icons.go b/client/icons.go new file mode 100644 index 00000000..b2b48107 --- /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/ioutil" + "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 := ioutil.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..f9953d77 --- /dev/null +++ b/client/icons_test.go @@ -0,0 +1,74 @@ +// -*- 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..ec7ff994 --- /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 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..3c3768f8 --- /dev/null +++ b/client/login.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 client + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/snapcore/snapd/osutil" +) + +// 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") +} + +// writeAuthData saves authentication details for later reuse through ReadAuthData +func writeAuthData(user User) error { + real, err := osutil.UserMaybeSudoUser() + if err != nil { + return err + } + + uid, gid, err := osutil.UidGid(real) + if err != nil { + return err + } + + targetFile := storeAuthDataFilename(real.HomeDir) + + if err := osutil.MkdirAllChown(filepath.Dir(targetFile), 0700, uid, gid); err != nil { + return err + } + + outStr, err := json.Marshal(user) + if err != nil { + return nil + } + + return osutil.AtomicWriteFileChown(targetFile, []byte(outStr), 0600, 0, uid, gid) +} + +// readAuthData reads previously written authentication details +func readAuthData() (*User, error) { + sourceFile := storeAuthDataFilename("") + f, err := os.Open(sourceFile) + if err != nil { + return nil, err + } + defer f.Close() + + var user User + dec := json.NewDecoder(f) + if err := dec.Decode(&user); err != nil { + return nil, err + } + + return &user, nil +} + +// removeAuthData removes any previously written authentication details. +func removeAuthData() error { + filename := storeAuthDataFilename("") + return os.Remove(filename) +} diff --git a/client/login_test.go b/client/login_test.go new file mode 100644 index 00000000..dafbe518 --- /dev/null +++ b/client/login_test.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_test + +import ( + "fmt" + "io/ioutil" + "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 := ioutil.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 := ioutil.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, fmt.Sprintf("/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..72a307a3 --- /dev/null +++ b/client/model.go @@ -0,0 +1,104 @@ +// -*- 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" + "net/url" + + "golang.org/x/xerrors" + + "github.com/snapcore/snapd/asserts" +) + +type remodelData struct { + NewModel string `json:"new-model"` +} + +// Remodel tries to remodel the system with the given assertion data +func (client *Client) Remodel(b []byte) (changeID string, err error) { + data, err := json.Marshal(&remodelData{ + NewModel: string(b), + }) + 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)) +} + +// 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..cb80fa57 --- /dev/null +++ b/client/model_test.go @@ -0,0 +1,176 @@ +// -*- 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/ioutil" + "net/http" + + "golang.org/x/xerrors" + + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/asserts" +) + +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"}`)) + 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) + c.Assert(err, IsNil) + c.Check(id, Equals, "d728") + c.Assert(cs.req.Header.Get("Content-Type"), Equals, "application/json") + + body, err := ioutil.ReadAll(cs.req.Body) + c.Assert(err, IsNil) + jsonBody := make(map[string]string) + err = json.Unmarshal(body, &jsonBody) + c.Assert(err, IsNil) + c.Check(jsonBody, HasLen, 1) + c.Check(jsonBody["new-model"], Equals, string(remodelJsonData)) +} + +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) +} diff --git a/client/packages.go b/client/packages.go new file mode 100644 index 00000000..ab1a3c86 --- /dev/null +++ b/client/packages.go @@ -0,0 +1,270 @@ +// -*- 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 ( + "encoding/json" + "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"` + Contact string `json:"contact"` + License string `json:"license,omitempty"` + CommonIDs []string `json:"common-ids,omitempty"` + MountedFrom string `json:"mounted-from,omitempty"` + CohortKey string `json:"cohort-key,omitempty"` + Website string `json:"website,omitempty"` + + Prices map[string]float64 `json:"prices,omitempty"` + Screenshots []snap.ScreenshotInfo `json:"screenshots,omitempty"` + Media snap.MediaInfos `json:"media,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"` +} + +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"` +} + +func (s *Snap) MarshalJSON() ([]byte, error) { + type auxSnap Snap // use auxiliary type so that Go does not call Snap.MarshalJSON() + // separate type just for marshalling + m := struct { + auxSnap + InstallDate *time.Time `json:"install-date,omitempty"` + }{ + auxSnap: auxSnap(*s), + } + if !s.InstallDate.IsZero() { + m.InstallDate = &s.InstallDate + } + return json.Marshal(&m) +} + +// 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 + + Section string + Private bool + Scope string + + Refresh bool +} + +var ErrNoSnapsInstalled = errors.New("no snaps installed") + +type ListOptions struct { + All bool +} + +// 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 +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 +} + +// 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.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..a5f81cf6 --- /dev/null +++ b/client/packages_test.go @@ -0,0 +1,407 @@ +// -*- 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" + "io/ioutil" + "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) 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) { + // 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", + "website": "http://example.com/funky", + "common-ids": ["org.funky.snap"], + "store-url": "https://snapcraft.io/chatroom" + } + }` + 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, 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, + InstallDate: time.Date(2016, 1, 2, 15, 4, 5, 0, time.UTC), + 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", + Website: "http://example.com/funky", + StoreURL: "https://snapcraft.io/chatroom", + }) +} + +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) 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 := ioutil.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/snap_op.go b/client/snap_op.go new file mode 100644 index 00000000..3e239cb7 --- /dev/null +++ b/client/snap_op.go @@ -0,0 +1,399 @@ +// -*- 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 client + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "os" + "path/filepath" +) + +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"` + Purge bool `json:"purge,omitempty"` + Amend bool `json:"amend,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") +} + +func (opts *SnapOptions) writeModeFields(mw *multipart.Writer) error { + fields := []struct { + f string + b bool + }{ + {"devmode", opts.DevMode}, + {"classic", opts.Classic}, + {"jailmode", opts.JailMode}, + {"dangerous", opts.Dangerous}, + } + for _, o := range fields { + if err := writeFieldBool(mw, o.f, o.b); err != nil { + return err + } + } + + return nil +} + +func (opts *SnapOptions) writeOptionFields(mw *multipart.Writer) error { + if err := writeFieldBool(mw, "ignore-running", opts.IgnoreRunning); err != nil { + return err + } + return writeFieldBool(mw, "unaliased", opts.Unaliased) +} + +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"` +} + +// 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) 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) { + if options != nil { + return "", fmt.Errorf("cannot use options for multi-action") // (yet) + } + _, 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 { + action.Users = options.Users + } + 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", path) + } + + action := actionData{ + Action: "install", + Name: name, + SnapPath: path, + SnapOptions: options, + } + + pr, pw := io.Pipe() + mw := multipart.NewWriter(pw) + go sendSnapFile(path, f, 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 sendSnapFile(snapPath string, snapFile *os.File, pw *io.PipeWriter, mw *multipart.Writer, action *actionData) { + defer snapFile.Close() + + if action.SnapOptions == nil { + action.SnapOptions = &SnapOptions{} + } + fields := []struct { + name string + value string + }{ + {"action", action.Action}, + {"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 + } + + fw, err := mw.CreateFormFile("snap", filepath.Base(snapPath)) + if err != nil { + pw.CloseWithError(err) + return + } + + _, err = io.Copy(fw, snapFile) + 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..86c7a183 --- /dev/null +++ b/client/snap_op_test.go @@ -0,0 +1,569 @@ +// -*- 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" + "errors" + "fmt" + "io" + "io/ioutil" + "mime" + "mime/multipart" + "net/http" + "path/filepath" + + "gopkg.in/check.v1" + + "github.com/snapcore/snapd/client" +) + +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"}, +} + +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"}, +} + +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 := ioutil.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 := ioutil.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) 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 := ioutil.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 := ioutil.WriteFile(snap, bodyData, 0644) + c.Assert(err, check.IsNil) + + id, err := cs.cli.InstallPath(snap, "", nil) + c.Assert(err, check.IsNil) + + body, err := ioutil.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.Check(cs.req.Method, check.Equals, "POST") + c.Check(cs.req.URL.Path, check.Equals, fmt.Sprintf("/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) 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 := ioutil.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 := ioutil.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, fmt.Sprintf("/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 := ioutil.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 := ioutil.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, fmt.Sprintf("/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) 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 := ioutil.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 := ioutil.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) + + // nor does InstallMany (whether it fails because any option + // at all was provided, or because dangerous was provided, is + // unimportant) + _, err = cs.cli.InstallMany([]string{"foo"}, &opts) + c.Assert(err, check.NotNil) +} + +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 := ioutil.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 := ioutil.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 = ioutil.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 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 := ioutil.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, fmt.Sprintf("/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}, + } + 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 := ioutil.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 := ioutil.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 := ioutil.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 := ioutil.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) +} diff --git a/client/snapctl.go b/client/snapctl.go new file mode 100644 index 00000000..77661852 --- /dev/null +++ b/client/snapctl.go @@ -0,0 +1,92 @@ +// -*- 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" + "io/ioutil" +) + +// 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"` +} + +// 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 { + stdinData, err = ioutil.ReadAll(stdin) + if err != nil { + return nil, nil, fmt.Errorf("cannot read stdin: %v", err) + } + } + + 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..1e535fbc --- /dev/null +++ b/client/snapctl_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 client_test + +import ( + "bytes" + "encoding/base64" + "encoding/json" + + "github.com/snapcore/snapd/client" + + "gopkg.in/check.v1" +) + +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) + } +} diff --git a/client/snapshot.go b/client/snapshot.go new file mode 100644 index 00000000..8dcaf8fd --- /dev/null +++ b/client/snapshot.go @@ -0,0 +1,274 @@ +// -*- 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"` + // 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 + 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 + specificErr := r.err(client, rsp.StatusCode) + if err != 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..14c6c77f --- /dev/null +++ b/client/snapshot_test.go @@ -0,0 +1,299 @@ +// -*- 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/ioutil" + "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) TestClientExportSnapshot(c *check.C) { + type tableT struct { + content string + contentType string + status int + } + + table := []tableT{ + {"dummy-export", client.SnapshotExportMediaType, 200}, + {"dummy-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 := ioutil.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 := ioutil.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 actually different + 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) + + // identical to sh1 except for 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) +} + +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}, + }} + // ss2 is the same ss1 but in a different order with different setID + // (but that does not matter for the content hash) + 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}, + }} + + 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..45682708 --- /dev/null +++ b/client/systems.go @@ -0,0 +1,140 @@ +// -*- 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" + + "golang.org/x/xerrors" + + "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"` +} + +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 +} diff --git a/client/systems_test.go b/client/systems_test.go new file mode 100644 index 00000000..9b2bde12 --- /dev/null +++ b/client/systems_test.go @@ -0,0 +1,217 @@ +// -*- 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/ioutil" + + "gopkg.in/check.v1" + + "github.com/snapcore/snapd/client" + "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 := ioutil.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 := ioutil.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") +} 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..56aae3e9 --- /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/ioutil" + + . "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 := ioutil.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 := ioutil.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 := ioutil.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 := ioutil.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..e13adf10 --- /dev/null +++ b/client/validate.go @@ -0,0 +1,132 @@ +// -*- 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"` + // set sequence key and optional pinned-at (=) + Sequence int `json:"sequence,omitempty"` + PinnedAt int `json:"pinned-at,omitempty"` + // set current state + 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. +func (client *Client) ApplyValidationSet(accountID, name string, opts *ValidateApplyOptions) error { + if accountID == "" || name == "" { + return 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 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 apply validation set: %w" + return xerrors.Errorf(fmt, err) + } + return 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..e7265c40 --- /dev/null +++ b/client/validate_test.go @@ -0,0 +1,201 @@ +// -*- 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/ioutil" + "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) TestApplyValidationSet(c *check.C) { + cs.rsp = `{ + "type": "sync", + "status-code": 200 + }` + opts := &client.ValidateApplyOptions{Mode: "monitor", Sequence: 3} + c.Assert(cs.cli.ApplyValidationSet("foo", "bar", opts), 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 := ioutil.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) 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 := ioutil.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/.indent.pro b/cmd/.indent.pro new file mode 100644 index 00000000..f410d3c6 --- /dev/null +++ b/cmd/.indent.pro @@ -0,0 +1,34 @@ +-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 diff --git a/cmd/Makefile.am b/cmd/Makefile.am new file mode 100644 index 00000000..7f23af00 --- /dev/null +++ b/cmd/Makefile.am @@ -0,0 +1,538 @@ + +EXTRA_DIST = VERSION snap-confine/PORTING +CLEANFILES = +TESTS = +libexec_PROGRAMS = +dist_man_MANS = +noinst_PROGRAMS = +noinst_LIBRARIES = + +CHECK_CFLAGS = -Wall -Wextra -Wmissing-prototypes -Wstrict-prototypes \ + -Wno-missing-field-initializers -Wno-unused-parameter + +# Make all warnings errors when building for unit tests +if WITH_UNIT_TESTS +CHECK_CFLAGS += -Werror +endif + +subdirs = \ + libsnap-confine-private \ + snap-confine \ + 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 + $(HAVE_VALGRIND) ./libsnap-confine-private/unit-tests + SNAP_DEVICE_HELPER=$(srcdir)/snap-confine/snap-device-helper $(HAVE_VALGRIND) ./snap-confine/unit-tests + $(HAVE_VALGRIND) ./system-shutdown/unit-tests +else +check-unit-tests: + echo "unit tests are disabled (rebuild with --enable-unit-tests)" +endif + +new_format = \ + libsnap-confine-private/cgroup-support.c \ + libsnap-confine-private/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-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 + 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 + 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 && GOPATH=$(or $(GOPATH),$(realpath $(srcdir)/../../../../..)) go build -v +snap-seccomp/snap-seccomp: snap-seccomp/*.go + cd snap-seccomp && GOPATH=$(or $(GOPATH),$(realpath $(srcdir)/../../../../..)) 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/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 +libsnap_confine_private_a_CFLAGS = $(CHECK_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 = $(CHECK_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/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 = $(CHECK_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 = $(CHECK_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) + +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/snap-confine-args-test.c \ + snap-confine/snap-confine-invocation-test.c \ + snap-confine/snap-device-helper-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 +## + +EXTRA_DIST += \ + snap-confine/snap-device-helper + +# NOTE: This makes distcheck fail but it is required for udev, so go figure. +# http://www.gnu.org/software/automake/manual/automake.html#Hard_002dCoded-Install-Paths +# +# Install support script for udev rules +install-exec-local:: + install -d -m 755 $(DESTDIR)$(libexecdir) + install -m 755 $(srcdir)/snap-confine/snap-device-helper $(DESTDIR)$(libexecdir) + +## +## 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_CFLAGS = $(CHECK_CFLAGS) $(AM_CFLAGS) +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 +system_shutdown_system_shutdown_CFLAGS = $(CHECK_CFLAGS) $(filter-out -fPIE -pie,$(CFLAGS)) -static +system_shutdown_system_shutdown_LDFLAGS = $(filter-out -fPIE -pie,$(LDFLAGS)) -static + +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 = $(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-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 + +## +## 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 + +## +## snapd-apparmor +## + +EXTRA_DIST += snapd-apparmor/snapd-apparmor + +install-exec-local:: + install -d -m 755 $(DESTDIR)$(libexecdir) +if APPARMOR + install -m 755 $(srcdir)/snapd-apparmor/snapd-apparmor $(DESTDIR)$(libexecdir) +endif diff --git a/cmd/autogen.sh b/cmd/autogen.sh new file mode 100755 index 00000000..bba9918d --- /dev/null +++ b/cmd/autogen.sh @@ -0,0 +1,58 @@ +#!/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 + +# Sanity 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|opensuse-tumbleweed) + extra_opts="--libexecdir=/usr/lib/snapd --enable-nvidia-biarch --with-32bit-libdir=/usr/lib --enable-merged-usr" + ;; + solus) + extra_opts="--enable-nvidia-biarch" + ;; +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..f5828da0 --- /dev/null +++ b/cmd/configure.ac @@ -0,0 +1,230 @@ +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)], +[[#define _GNU_SOURCE +#define _FILE_OFFSET_BITS 64 +#include +]]) + +# 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"], [ + 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_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..eac0912d --- /dev/null +++ b/cmd/libsnap-confine-private/apparmor-support.c @@ -0,0 +1,141 @@ +/* + * 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 +#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_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 ENOENT: + debug + ("apparmor is available but the interface but the interface is not available"); + break; + case EPERM: + // NOTE: fall-through + case EACCES: + debug + ("insufficient permissions to determine if apparmor is enabled"); + break; + default: + debug("apparmor is not enabled: %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); + // The label has a special value "unconfined" that is applied to all + // processes without a dedicated profile. If that label is used then the + // current process is not confined. All other labels imply confinement. + if (label != NULL && strcmp(label, SC_AA_UNCONFINED_STR) == 0) { + apparmor->is_confined = false; + } else { + apparmor->is_confined = true; + } + // 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 { + 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) { + if (secure_getenv("SNAPPY_LAUNCHER_INSIDE_TESTS") == NULL) { + 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..b90f285c --- /dev/null +++ b/cmd/libsnap-confine-private/apparmor-support.h @@ -0,0 +1,93 @@ +/* + * 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, +}; + +/** + * 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/cgroup-freezer-support.c b/cmd/libsnap-confine-private/cgroup-freezer-support.c new file mode 100644 index 00000000..2502ee09 --- /dev/null +++ b/cmd/libsnap-confine-private/cgroup-freezer-support.c @@ -0,0 +1,125 @@ +/* + * 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); + } + } + debug("found 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.c b/cmd/libsnap-confine-private/cgroup-support.c new file mode 100644 index 00000000..f92abb7b --- /dev/null +++ b/cmd/libsnap-confine-private/cgroup-support.c @@ -0,0 +1,99 @@ +/* + * 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-support.h" + +#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 CGRUOP2_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() { + static bool did_warn = false; + 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) { + if (!did_warn) { + fprintf(stderr, "WARNING: cgroup v2 is not fully supported yet, proceeding with partial confinement\n"); + did_warn = true; + } + return true; + } + return false; +} diff --git a/cmd/libsnap-confine-private/cgroup-support.h b/cmd/libsnap-confine-private/cgroup-support.h new file mode 100644 index 00000000..f7f0ebaf --- /dev/null +++ b/cmd/libsnap-confine-private/cgroup-support.h @@ -0,0 +1,40 @@ +/* + * 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_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); + +#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..889b1f1f --- /dev/null +++ b/cmd/libsnap-confine-private/classic-test.c @@ -0,0 +1,190 @@ +/* + * 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_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_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 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); +} diff --git a/cmd/libsnap-confine-private/classic.c b/cmd/libsnap-confine-private/classic.c new file mode 100644 index 00000000..cbfa1baf --- /dev/null +++ b/cmd/libsnap-confine-private/classic.c @@ -0,0 +1,58 @@ +#include "config.h" +#include "classic.h" +#include "../libsnap-confine-private/cleanup-funcs.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; + } +} diff --git a/cmd/libsnap-confine-private/classic.h b/cmd/libsnap-confine-private/classic.h new file mode 100644 index 00000000..ee317f44 --- /dev/null +++ b/cmd/libsnap-confine-private/classic.h @@ -0,0 +1,33 @@ +/* + * 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); + +#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..695c54e7 --- /dev/null +++ b/cmd/libsnap-confine-private/cleanup-funcs-test.c @@ -0,0 +1,153 @@ +/* + * 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 + +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_close(mock_fstab_fd, &err); + g_assert_no_error(err); + /* XXX: not strictly needed as the test only calls setmntent */ + const char *mock_fstab_data = "/dev/foo / ext4 defaults 0 1"; + g_file_set_contents(mock_fstab, mock_fstab_data, -1, &err); + g_assert_no_error(err); + + 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 __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); +} diff --git a/cmd/libsnap-confine-private/cleanup-funcs.c b/cmd/libsnap-confine-private/cleanup-funcs.c new file mode 100644 index 00000000..369235cb --- /dev/null +++ b/cmd/libsnap-confine-private/cleanup-funcs.c @@ -0,0 +1,61 @@ +/* + * 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_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..b1fee959 --- /dev/null +++ b/cmd/libsnap-confine-private/cleanup-funcs.h @@ -0,0 +1,79 @@ +/* + * 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); + +/** + * 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/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..5d4ce082 --- /dev/null +++ b/cmd/libsnap-confine-private/error.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 . + * + */ +#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..dd2d8820 --- /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..eec406e3 --- /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(!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(!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_file_set_contents(pname, "", -1, NULL); + + g_assert(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(!sc_feature_enabled(SC_FEATURE_PARALLEL_INSTANCES)); + + char pname[PATH_MAX]; + sc_must_snprintf(pname, sizeof pname, "%s/parallel-instances", d); + g_file_set_contents(pname, "", -1, NULL); + + g_assert(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(!sc_feature_enabled(SC_FEATURE_HIDDEN_SNAP_FOLDER)); + + char pname[PATH_MAX]; + sc_must_snprintf(pname, sizeof pname, "%s/hidden-snap-folder", d); + g_file_set_contents(pname, "", -1, NULL); + + g_assert(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..129bfe01 --- /dev/null +++ b/cmd/libsnap-confine-private/infofile-test.c @@ -0,0 +1,265 @@ +/* + * 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" + "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" + "[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 __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); +} diff --git a/cmd/libsnap-confine-private/infofile.c b/cmd/libsnap-confine-private/infofile.c new file mode 100644 index 00000000..d7378e2d --- /dev/null +++ b/cmd/libsnap-confine-private/infofile.c @@ -0,0 +1,144 @@ +/* + * 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'; + + /* 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..a8f6389f --- /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, is deferences + * 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, is deferences + * 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..9fa51ed1 --- /dev/null +++ b/cmd/libsnap-confine-private/mount-opt-test.c @@ -0,0 +1,343 @@ +/* + * 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_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_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_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..a06022ad --- /dev/null +++ b/cmd/libsnap-confine-private/mountinfo-test.c @@ -0,0 +1,285 @@ +/* + * 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 __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); +} diff --git a/cmd/libsnap-confine-private/mountinfo.c b/cmd/libsnap-confine-private/mountinfo.c new file mode 100644 index 00000000..77ef84f9 --- /dev/null +++ b/cmd/libsnap-confine-private/mountinfo.c @@ -0,0 +1,334 @@ +/* + * 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(sc_mountinfo_entry * entry, + const char *line, size_t *offset) +{ + 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 == ' ') { + // 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. + 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; +} + +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_next_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..c6b18eae --- /dev/null +++ b/cmd/libsnap-confine-private/snap-test.c @@ -0,0 +1,602 @@ +/* + * 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")); + + // 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.name.app.hook.f00")); +} + +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); + + 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..922daf5f --- /dev/null +++ b/cmd/libsnap-confine-private/snap.c @@ -0,0 +1,315 @@ +/* + * 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-z])*)$"; + + 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; + } + // 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..163b6e3d --- /dev/null +++ b/cmd/libsnap-confine-private/string-utils-test.c @@ -0,0 +1,872 @@ +/* + * 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 }; + + // Try to append a string that's one character too long. + sc_string_append(buf, sizeof buf, "1234"); + + 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 }; + 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: 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 }; + 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: 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..62c18c27 --- /dev/null +++ b/cmd/libsnap-confine-private/string-utils.c @@ -0,0 +1,282 @@ +/* + * 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; + } + + size_t alen = strlen(a); + size_t blen = strlen(b); + + if (alen != blen) { + return false; + } + + return strncmp(a, b, alen) == 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); + size_t slen = strlen(str); + + if (slen < xlen) { + return false; + } + + 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..c3e17a18 --- /dev/null +++ b/cmd/libsnap-confine-private/test-utils.c @@ -0,0 +1,108 @@ +/* + * 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 + +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_exit_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..008d5315 --- /dev/null +++ b/cmd/libsnap-confine-private/tool.c @@ -0,0 +1,239 @@ +/* + * 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 + strlen("XDG_RUNTIME_DIR=")]; + if (xdg_runtime_dir != NULL) { + sc_must_snprintf(xdg_runtime_dir_env, + sizeof(xdg_runtime_dir_env), + "XDG_RUNTIME_DIR=%s", xdg_runtime_dir); + } + + 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, 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) < 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"); + } + 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..14709179 --- /dev/null +++ b/cmd/libsnap-confine-private/utils-test.c @@ -0,0 +1,203 @@ +/* + * 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_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); + } +} + +/** + * 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 __attribute__((constructor)) init(void) +{ + g_test_add_func("/utils/parse_bool", test_parse_bool); + 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); +} diff --git a/cmd/libsnap-confine-private/utils.c b/cmd/libsnap-confine-private/utils.c new file mode 100644 index 00000000..8cd9eb87 --- /dev/null +++ b/cmd/libsnap-confine-private/utils.c @@ -0,0 +1,239 @@ +/* + * 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 "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. + **/ +static 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; +} diff --git a/cmd/libsnap-confine-private/utils.h b/cmd/libsnap-confine-private/utils.h new file mode 100644 index 00000000..34e9ea63 --- /dev/null +++ b/cmd/libsnap-confine-private/utils.h @@ -0,0 +1,104 @@ +/* + * 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, ...); + +/** + * 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); + +/** + * 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); +#endif diff --git a/cmd/snap-bootstrap/README.md b/cmd/snap-bootstrap/README.md new file mode 100644 index 00000000..3eb07c9e --- /dev/null +++ b/cmd/snap-bootstrap/README.md @@ -0,0 +1,21 @@ +# _snap-bootstrap_ + +Welcome to the world of the initramfs of UC20! + +_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. + +## Degraded recover mode + +When booting into recover mode, _snap-bootstrap_ has some additional logic setup to try and be as robust as possible. This logic is fairly complicated and best explained in the following state diagram showing the states and transitions that _snap-bootstrap_ operates in during recover mode, which has been called 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. \ No newline at end of file diff --git a/cmd/snap-bootstrap/cmd_initramfs_mounts.go b/cmd/snap-bootstrap/cmd_initramfs_mounts.go new file mode 100644 index 00000000..b294786e --- /dev/null +++ b/cmd/snap-bootstrap/cmd_initramfs_mounts.go @@ -0,0 +1,1412 @@ +// -*- 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 + +import ( + "crypto/subtle" + "encoding/json" + "fmt" + "io/ioutil" + "os" + "path/filepath" + "strings" + "syscall" + + "github.com/jessevdk/go-flags" + + "github.com/snapcore/snapd/asserts" + "github.com/snapcore/snapd/boot" + "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/overlord/state" + "github.com/snapcore/snapd/secboot" + "github.com/snapcore/snapd/snap" + "github.com/snapcore/snapd/snap/squashfs" + "github.com/snapcore/snapd/sysconfig" + + // to set sysconfig.ApplyFilesystemOnlyDefaultsImpl + _ "github.com/snapcore/snapd/overlord/configstate/configcore" +) + +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(args []string) error { + return generateInitramfsMounts() +} + +var ( + osutilIsMounted = osutil.IsMounted + + snapTypeToMountDir = map[snap.Type]string{ + snap.TypeBase: "base", + snap.TypeKernel: "kernel", + snap.TypeSnapd: "snapd", + } + + 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 +) + +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 ioutil.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, + } + + switch mode { + case "recover": + return generateMountsModeRecover(mst) + case "install": + return generateMountsModeInstall(mst) + case "run": + return generateMountsModeRun(mst) + } + // this should never be reached + return fmt.Errorf("internal error: mode in generateInitramfsMounts not handled") +} + +// 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 := generateMountsCommonInstallRecover(mst) + if 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 + } + if err := modeEnv.WriteTo(boot.InitramfsWritableDir); 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 && err != state.ErrNoState { + return fmt.Errorf("cannot copy user state: %v", err) + } + + return nil +} + +// 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 { + consoleConfCompleteFile := filepath.Join(dst, "system-data/var/lib/console-conf/complete") + if err := os.MkdirAll(filepath.Dir(consoleConfCompleteFile), 0755); err != nil { + return err + } + return ioutil.WriteFile(consoleConfCompleteFile, nil, 0644) +} + +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) +} + +// 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 + + // 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 +} + +// 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) 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 + 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, partitionOptional bool) 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 + + // if the partition is optional (like ubuntu-save is) then don't + // report an error for ubuntu-save not being found and also set it + // as absent-but-optional + if unlockRes.PartDevice == "" && partitionOptional { + part.MountState = partitionAbsentOptional + } else { + // log the 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) *recoverModeStateMachine { + m := &recoverModeStateMachine{ + model: model, + disk: disk, + degradedState: &recoverDegradedState{ + ErrorLog: []string{}, + }, + } + // 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, _ := checkDataAndSavaPairing(boot.InitramfsHostWritableDir) + 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") + if err := m.setFindState("ubuntu-boot", partUUID, findErr); 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 + fsckSystemdOpts := &systemdMountOptions{ + NeedsFsck: true, + } + mountErr := doSystemdMount(part.fsDevice, boot.InitramfsUbuntuBootDir, fsckSystemdOpts) + 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 := filepath.Join(boot.InitramfsBootEncryptionKeyDir, "ubuntu-data.sealed-key") + 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, + } + 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.unlockSaveFallbackKey, 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) { + // 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, + } + // 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 := filepath.Join(boot.InitramfsSeedEncryptionKeyDir, "ubuntu-data.recovery.sealed-key") + unlockRes, unlockErr := secbootUnlockVolumeUsingSealedKeyIfEncrypted(m.disk, "ubuntu-data", dataFallbackKey, unlockOpts) + const partitionMandatory = false + if err := m.setUnlockStateWithFallbackKey("ubuntu-data", unlockRes, unlockErr, partitionMandatory); 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.unlockSaveFallbackKey, 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 + mountErr := doSystemdMount(data.fsDevice, boot.InitramfsHostUbuntuDataDir, nil) + if err := m.setMountState("ubuntu-data", boot.InitramfsHostUbuntuDataDir, mountErr); err != nil { + return nil, err + } + if mountErr == nil && m.isEncryptedDev { + // 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.unlockSaveRunKey, nil + } + + // otherwise we always fall back to unlocking save with the fallback key, + // this could be two cases: + // 1. we are unencrypted in which case the secboot function used in + // unlockSaveRunKey will fail and then proceed to trying the fallback key + // function anyways which uses a secboot function that is suitable for + // unencrypted data + // 2. 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.unlockSaveFallbackKey, nil +} + +func (m *recoverModeStateMachine) unlockSaveRunKey() (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 := filepath.Join(dirs.SnapFDEDirUnder(boot.InitramfsHostWritableDir), "ubuntu-save.key") + key, err := ioutil.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.unlockSaveFallbackKey, 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.unlockSaveFallbackKey, nil + } + + // unlocked it properly, go mount it + return m.mountSave, nil +} + +func (m *recoverModeStateMachine) unlockSaveFallbackKey() (stateFunc, error) { + // remember what we assumed about encryption before looking at + // save + assumeEncrypted := m.isEncryptedDev + + // try to unlock save 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 save + AllowRecoveryKey: true, + } + saveFallbackKey := filepath.Join(boot.InitramfsSeedEncryptionKeyDir, "ubuntu-save.recovery.sealed-key") + // 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) + const partitionOptionalIfUnencrypted = true + if err := m.setUnlockStateWithFallbackKey("ubuntu-save", unlockRes, unlockErr, partitionOptionalIfUnencrypted); 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 + } + + // do a consistency check to make sure that if we found ubuntu-data + // unencrypted that we don't also mount ubuntu-save as encrypted + data := m.degradedState.partition("ubuntu-data") + if unlockRes.IsEncrypted && data.FindState == partitionFound && !assumeEncrypted { + return nil, fmt.Errorf("inconsistent encryption status for disk %s: ubuntu-data (device %s) was found unencrypted but ubuntu-save (device %s) was found to be encrypted", m.disk.Dev(), data.fsDevice, unlockRes.FsDevice) + } + + // 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 ? + mountErr := doSystemdMount(save.fsDevice, boot.InitramfsUbuntuSaveDir, nil) + 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 + } + + // 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) + 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 + } + + // 3.1 write out degraded.json if we ended up falling back somewhere + if machine.degraded() { + b, err := json.Marshal(machine.degradedState) + 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 + if err := ioutil.WriteFile(filepath.Join(dirs.SnapBootstrapRunDir, "degraded.json"), b, 0644); 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 + } + if err := modeEnv.WriteTo(boot.InitramfsWritableDir); 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 +} + +// checkDataAndSavaPairing make sure that ubuntu-data and ubuntu-save +// come from the same install by comparing secret markers in them +func checkDataAndSavaPairing(rootdir string) (bool, error) { + // read the secret marker file from ubuntu-data + markerFile1 := filepath.Join(dirs.SnapFDEDirUnder(rootdir), "marker") + marker1, err := ioutil.ReadFile(markerFile1) + if err != nil { + return false, err + } + // read the secret marker file from ubuntu-save + markerFile2 := filepath.Join(dirs.SnapFDEDirUnderSave(boot.InitramfsUbuntuSaveDir), "marker") + marker2, err := ioutil.ReadFile(markerFile2) + if err != nil { + return false, err + } + return subtle.ConstantTimeCompare(marker1, marker2) == 1, nil +} + +// mountPartitionMatchingKernelDisk 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 mountPartitionMatchingKernelDisk(dir, fallbacklabel string) error { + partuuid, err := bootFindPartitionUUIDForBootedKernelDisk() + // 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) + if err != nil { + // no luck, try mounting by label instead + partSrc = filepath.Join("/dev/disk/by-label", fallbacklabel) + } + + 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, + } + return doSystemdMount(partSrc, dir, opts) +} + +func generateMountsCommonInstallRecover(mst *initramfsMountsState) (model *asserts.Model, sysSnaps map[snap.Type]snap.PlaceInfo, 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 := mountPartitionMatchingKernelDisk(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} + model, essSnaps, err := mst.ReadEssential("", typs) + if err != nil { + return nil, nil, fmt.Errorf("cannot load metadata and verify essential bootstrap snaps %v: %v", typs, err) + } + + // 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 + } + // 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]snap.PlaceInfo) + + for _, essentialSnap := range essSnaps { + if essentialSnap.EssentialType == snap.TypeGadget { + // don't need to mount the gadget anywhere, but we use the snap + // later hence it is loaded + continue + } + systemSnaps[essentialSnap.EssentialType] = essentialSnap.PlaceInfo() + + 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), nil); err != nil { + return nil, nil, err + } + } + + // 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 + mntOpts := &systemdMountOptions{ + Tmpfs: true, + } + err = doSystemdMount("tmpfs", boot.InitramfsDataDir, mntOpts) + if err != nil { + return nil, nil, err + } + + // finally get the gadget snap from the essential snaps and use it to + // configure the ephemeral system + // should only be one seed snap + gadgetPath := "" + for _, essentialSnap := range essSnaps { + if essentialSnap.EssentialType == snap.TypeGadget { + gadgetPath = essentialSnap.Path + } + } + gadgetSnap := squashfs.New(gadgetPath) + + // 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, + GadgetSnap: gadgetSnap, + } + if err := sysconfig.ConfigureTargetSystem(configOpts); err != nil { + return nil, nil, err + } + + return model, systemSnaps, err +} + +func maybeMountSave(disk disks.Disk, rootdir string, encrypted bool, mountOpts *systemdMountOptions) (haveSave bool, err error) { + var saveDevice string + if encrypted { + saveKey := filepath.Join(dirs.SnapFDEDirUnder(rootdir), "ubuntu-save.key") + // 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 := ioutil.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 +} + +func generateMountsModeRun(mst *initramfsMountsState) error { + // 1. mount ubuntu-boot + if err := mountPartitionMatchingKernelDisk(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 + } + + // 2. mount ubuntu-seed + // use the disk we mounted ubuntu-boot from as a reference to find + // ubuntu-seed and mount it + partUUID, err := disk.FindMatchingPartitionUUIDWithFsLabel("ubuntu-seed") + if err != nil { + 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 + fsckSystemdOpts := &systemdMountOptions{ + NeedsFsck: true, + } + if err := doSystemdMount(fmt.Sprintf("/dev/disk/by-partuuid/%s", partUUID), boot.InitramfsUbuntuSeedDir, fsckSystemdOpts); err != nil { + return err + } + + // 3.1. measure model + err = stampedAction("run-model-measured", func() error { + return secbootMeasureSnapModelWhenPossible(mst.UnverifiedBootModel) + }) + if 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.2. mount Data + runModeKey := filepath.Join(boot.InitramfsBootEncryptionKeyDir, "ubuntu-data.sealed-key") + opts := &secboot.UnlockVolumeUsingSealedKeyOptions{ + AllowRecoveryKey: true, + } + 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? + if err := doSystemdMount(unlockRes.FsDevice, boot.InitramfsDataDir, fsckSystemdOpts); err != nil { + return err + } + isEncryptedDev := unlockRes.IsEncrypted + + // 3.3. mount ubuntu-save (if present) + haveSave, err := maybeMountSave(disk, boot.InitramfsWritableDir, isEncryptedDev, fsckSystemdOpts) + 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 := checkDataAndSavaPairing(boot.InitramfsWritableDir) + 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(boot.InitramfsWritableDir) + if err != nil { + return err + } + + typs := []snap.Type{snap.TypeBase, snap.TypeKernel} + + // 4.2 choose base and kernel snaps (this includes updating modeenv if + // needed to try the base snap) + mounts, err := boot.InitramfsRunModeSelectSnapsToMount(typs, modeEnv) + 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 and kernel snaps + // make sure this is a deterministic order + for _, typ := range []snap.Type{snap.TypeBase, snap.TypeKernel} { + if sn, ok := mounts[typ]; ok { + dir := snapTypeToMountDir[typ] + snapPath := filepath.Join(dirs.SnapBlobDirUnder(boot.InitramfsWritableDir), sn.Filename()) + if err := doSystemdMount(snapPath, filepath.Join(boot.InitramfsRunMntDir, dir), nil); err != nil { + return err + } + } + } + + // 4.4 mount snapd snap only on first boot + if modeEnv.RecoverySystem != "" { + // load the recovery system and generate mount for snapd + _, essSnaps, err := mst.ReadEssential(modeEnv.RecoverySystem, []snap.Type{snap.TypeSnapd}) + if err != nil { + return fmt.Errorf("cannot load metadata and verify snapd snap: %v", err) + } + + return doSystemdMount(essSnaps[0].Path, filepath.Join(boot.InitramfsRunMntDir, "snapd"), nil) + } + + 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..31ee4164 --- /dev/null +++ b/cmd/snap-bootstrap/cmd_initramfs_mounts_nosecboot.go @@ -0,0 +1,52 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- +// +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() { + 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..b0953636 --- /dev/null +++ b/cmd/snap-bootstrap/cmd_initramfs_mounts_secboot.go @@ -0,0 +1,33 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- +// +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() { + 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..d12f739c --- /dev/null +++ b/cmd/snap-bootstrap/cmd_initramfs_mounts_test.go @@ -0,0 +1,4728 @@ +// -*- 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 ( + "bytes" + "encoding/json" + "fmt" + "io/ioutil" + "os" + "path/filepath" + "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/logger" + "github.com/snapcore/snapd/osutil" + "github.com/snapcore/snapd/osutil/disks" + "github.com/snapcore/snapd/secboot" + "github.com/snapcore/snapd/seed" + "github.com/snapcore/snapd/seed/seedtest" + "github.com/snapcore/snapd/snap" + "github.com/snapcore/snapd/systemd" + "github.com/snapcore/snapd/testutil" +) + +var brandPrivKey, _ = assertstest.GenerateKey(752) + +type initramfsMountsSuite struct { + testutil.BaseTest + + // makes available a bunch of helper (like MakeAssertedSnap) + *seedtest.TestingSeed20 + + Stdout *bytes.Buffer + + seedDir string + sysLabel string + model *asserts.Model + tmpDir string + + kernel snap.PlaceInfo + kernelr2 snap.PlaceInfo + core20 snap.PlaceInfo + core20r2 snap.PlaceInfo + snapd snap.PlaceInfo +} + +var _ = Suite(&initramfsMountsSuite{}) + +var ( + tmpfsMountOpts = &main.SystemdMountOptions{ + Tmpfs: true, + } + needsFsckDiskMountOpts = &main.SystemdMountOptions{ + NeedsFsck: true, + } + + // a boot disk without ubuntu-save + defaultBootDisk = &disks.MockDiskMapping{ + FilesystemLabelToPartUUID: map[string]string{ + "ubuntu-boot": "ubuntu-boot-partuuid", + "ubuntu-seed": "ubuntu-seed-partuuid", + "ubuntu-data": "ubuntu-data-partuuid", + }, + DiskHasPartitions: true, + DevNum: "default", + } + + defaultBootWithSaveDisk = &disks.MockDiskMapping{ + FilesystemLabelToPartUUID: map[string]string{ + "ubuntu-boot": "ubuntu-boot-partuuid", + "ubuntu-seed": "ubuntu-seed-partuuid", + "ubuntu-data": "ubuntu-data-partuuid", + "ubuntu-save": "ubuntu-save-partuuid", + }, + DiskHasPartitions: true, + DevNum: "default-with-save", + } + + defaultEncBootDisk = &disks.MockDiskMapping{ + FilesystemLabelToPartUUID: map[string]string{ + "ubuntu-boot": "ubuntu-boot-partuuid", + "ubuntu-seed": "ubuntu-seed-partuuid", + "ubuntu-data-enc": "ubuntu-data-enc-partuuid", + "ubuntu-save-enc": "ubuntu-save-enc-partuuid", + }, + DiskHasPartitions: true, + DevNum: "defaultEncDev", + } + + mockStateContent = `{"data":{"auth":{"users":[{"id":1,"name":"mvo"}],"macaroon-key":"not-a-cookie","last-id":1}},"some":{"other":"stuff"}}` +) + +// because 1.9 vet does not like xerrors.Errorf(".. %w") +type mockedWrappedError struct { + err error + fmt string +} + +func (m *mockedWrappedError) Unwrap() error { return m.err } + +func (m *mockedWrappedError) Error() string { return fmt.Sprintf(m.fmt, m.err) } + +func (s *initramfsMountsSuite) setupSeed(c *C, 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", + }) + + // 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) + + s.sysLabel = "20191118" + s.model = seed20.MakeSeed(c, s.sysLabel, "my-brand", "my-model", map[string]interface{}{ + "display-name": "my model", + "architecture": "amd64", + "base": "core20", + "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", + }}, + }, nil) + +} + +func (s *initramfsMountsSuite) SetUpTest(c *C) { + s.BaseTest.SetUpTest(c) + + s.Stdout = bytes.NewBuffer(nil) + + _, restore := logger.MockLogger() + s.AddCleanup(restore) + + s.tmpDir = c.MkDir() + + // mock /run/mnt + dirs.SetRootDir(s.tmpDir) + restore = func() { dirs.SetRootDir("") } + s.AddCleanup(restore) + + // setup the seed + s.setupSeed(c, nil) + + // make test snap PlaceInfo's for various boot functionality + var err error + 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.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.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 + })) +} + +// 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 makeSnapFilesOnEarlyBootUbuntuData(c *C, snaps ...snap.PlaceInfo) { + snapDir := dirs.SnapBlobDirUnder(boot.InitramfsWritableDir) + err := os.MkdirAll(snapDir, 0755) + c.Assert(err, IsNil) + for _, sn := range snaps { + snFilename := sn.Filename() + err = ioutil.WriteFile(filepath.Join(snapDir, snFilename), nil, 0644) + c.Assert(err, IsNil) + } +} + +func (s *initramfsMountsSuite) mockProcCmdlineContent(c *C, newContent string) { + mockProcCmdline := filepath.Join(c.MkDir(), "proc-cmdline") + err := ioutil.WriteFile(mockProcCmdline, []byte(newContent), 0644) + c.Assert(err, IsNil) + restore := osutil.MockProcCmdline(mockProcCmdline) + s.AddCleanup(restore) +} + +func (s *initramfsMountsSuite) 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(ioutil.WriteFile(keyPath, []byte(key), 0600), IsNil) + + if marker != "" { + markerPath := filepath.Join(dirs.SnapFDEDirUnder(rootDir), "marker") + c.Assert(ioutil.WriteFile(markerPath, []byte(marker), 0600), IsNil) + } +} + +func (s *initramfsMountsSuite) mockUbuntuSaveMarker(c *C, rootDir, marker string) { + markerPath := filepath.Join(rootDir, "device/fde", "marker") + c.Assert(os.MkdirAll(filepath.Dir(markerPath), 0700), IsNil) + c.Assert(ioutil.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 +} + +// this is a function so we evaluate InitramfsUbuntuBootDir, etc at the time of +// the test to pick up test-specific dirs.GlobalRootDir +func ubuntuLabelMount(label string, mode string) systemdMount { + mnt := systemdMount{ + opts: needsFsckDiskMountOpts, + } + switch label { + case "ubuntu-boot": + mnt.what = "/dev/disk/by-label/ubuntu-boot" + mnt.where = boot.InitramfsUbuntuBootDir + case "ubuntu-seed": + mnt.what = "/dev/disk/by-label/ubuntu-seed" + mnt.where = boot.InitramfsUbuntuSeedDir + // don't fsck in run mode + if mode == "run" { + mnt.opts = nil + } + case "ubuntu-data": + mnt.what = "/dev/disk/by-label/ubuntu-data" + mnt.where = boot.InitramfsDataDir + } + + return mnt +} + +// ubuntuPartUUIDMount returns a systemdMount for the partuuid disk, expecting +// that the partuuid contains in it the expected label for easier coding +func 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 + case strings.Contains(partuuid, "ubuntu-save"): + mnt.where = boot.InitramfsUbuntuSaveDir + } + + return mnt +} + +func (s *initramfsMountsSuite) 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.TypeKernel: + name = "pc-kernel" + dir = "kernel" + } + mnt.what = filepath.Join(s.seedDir, "snaps", name+"_1.snap") + mnt.where = filepath.Join(boot.InitramfsRunMntDir, dir) + + return mnt +} + +func (s *initramfsMountsSuite) 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.TypeKernel: + dir = "kernel" + } + + mnt.what = filepath.Join(dirs.SnapBlobDirUnder(boot.InitramfsWritableDir), sn.Filename()) + mnt.where = filepath.Join(boot.InitramfsRunMntDir, dir) + + return mnt +} + +func (s *initramfsMountsSuite) 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, comment) + return nil + }) +} + +func (s *initramfsMountsSuite) TestInitramfsMountsInstallModeHappy(c *C) { + 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{ + ubuntuLabelMount("ubuntu-seed", "install"), + s.makeSeedSnapSystemdMount(snap.TypeSnapd), + s.makeSeedSnapSystemdMount(snap.TypeKernel), + s.makeSeedSnapSystemdMount(snap.TypeBase), + { + "tmpfs", + boot.InitramfsDataDir, + tmpfsMountOpts, + }, + }, nil) + defer restore() + + _, err := main.Parser().ParseArgs([]string{"initramfs-mounts"}) + c.Assert(err, IsNil) + + modeEnv := dirs.SnapModeenvFileUnder(boot.InitramfsWritableDir) + c.Check(modeEnv, testutil.FileEquals, `mode=install +recovery_system=20191118 +base=core20_1.snap +model=my-brand/my-model +grade=signed +`) + cloudInitDisable := filepath.Join(boot.InitramfsWritableDir, "_writable_defaults/etc/cloud/cloud-init.disabled") + c.Check(cloudInitDisable, testutil.FilePresent) + + c.Check(sealedKeysLocked, Equals, true) +} + +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, [][]string{ + {"meta/gadget.yaml", gadgetYamlDefaults}, + }) + + s.mockProcCmdlineContent(c, "snapd_recovery_mode=install snapd_recovery_system="+s.sysLabel) + + restore := s.mockSystemdMountSequence(c, []systemdMount{ + ubuntuLabelMount("ubuntu-seed", "install"), + s.makeSeedSnapSystemdMount(snap.TypeSnapd), + s.makeSeedSnapSystemdMount(snap.TypeKernel), + s.makeSeedSnapSystemdMount(snap.TypeBase), + { + "tmpfs", + boot.InitramfsDataDir, + tmpfsMountOpts, + }, + }, 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(boot.InitramfsWritableDir) + c.Check(modeEnv, testutil.FileEquals, `mode=install +recovery_system=20191118 +base=core20_1.snap +model=my-brand/my-model +grade=signed +`) + + cloudInitDisable := filepath.Join(boot.InitramfsWritableDir, "_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(boot.InitramfsWritableDir, "_writable_defaults/etc/ssh/sshd_not_to_be_run")), Equals, true) + c.Assert(osutil.FileExists(filepath.Join(boot.InitramfsWritableDir, "_writable_defaults/var/lib/console-conf/complete")), Equals, true) + exists, _, _ := osutil.DirExists(filepath.Join(boot.InitramfsWritableDir, "_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(boot.InitramfsWritableDir, "_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, + }, + s.makeSeedSnapSystemdMount(snap.TypeSnapd), + s.makeSeedSnapSystemdMount(snap.TypeKernel), + s.makeSeedSnapSystemdMount(snap.TypeBase), + { + "tmpfs", + boot.InitramfsDataDir, + tmpfsMountOpts, + }, + }, nil) + defer restore() + + _, err := main.Parser().ParseArgs([]string{"initramfs-mounts"}) + c.Assert(err, IsNil) + + modeEnv := dirs.SnapModeenvFileUnder(boot.InitramfsWritableDir) + c.Check(modeEnv, testutil.FileEquals, `mode=install +recovery_system=20191118 +base=core20_1.snap +model=my-brand/my-model +grade=signed +`) + cloudInitDisable := filepath.Join(boot.InitramfsWritableDir, "_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{ + ubuntuLabelMount("ubuntu-boot", "run"), + ubuntuPartUUIDMount("ubuntu-seed-partuuid", "run"), + ubuntuPartUUIDMount("ubuntu-data-partuuid", "run"), + 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() + + 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(boot.InitramfsWritableDir) + c.Assert(err, IsNil) + + _, err = main.Parser().ParseArgs([]string{"initramfs-mounts"}) + c.Assert(err, IsNil) +} + +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{ + ubuntuLabelMount("ubuntu-boot", "run"), + ubuntuPartUUIDMount("ubuntu-seed-partuuid", "run"), + ubuntuPartUUIDMount("ubuntu-data-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() + + 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(boot.InitramfsWritableDir) + 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", "/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") + 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) + return n%2 == 0, nil + case 3, 4: + c.Assert(where, Equals, snapdMnt) + return n%2 == 0, nil + case 5, 6: + c.Assert(where, Equals, kernelMnt) + return n%2 == 0, nil + case 7, 8: + c.Assert(where, Equals, baseMnt) + return n%2 == 0, nil + case 9, 10: + c.Assert(where, Equals, boot.InitramfsDataDir) + return n%2 == 0, nil + default: + c.Errorf("unexpected IsMounted check on %s", where) + return false, fmt.Errorf("unexpected IsMounted check on %s", where) + } + }) + 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() + + 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(boot.InitramfsWritableDir) + 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.target", + "initrd-fs.target", + "initrd-switch-root.target", + "local-fs.target", + } { + for _, mountUnit := range []string{ + systemd.EscapeUnitNamePath(boot.InitramfsUbuntuSeedDir), + systemd.EscapeUnitNamePath(snapdMnt), + systemd.EscapeUnitNamePath(kernelMnt), + systemd.EscapeUnitNamePath(baseMnt), + 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] +Requires=%[1]s +After=%[1]s +`, mountUnit+".mount")) + } + } + + // 2 IsMounted calls per mount point, so 10 total IsMounted calls + c.Assert(n, Equals, 10) + + c.Assert(cmd.Calls(), DeepEquals, [][]string{ + { + "systemd-mount", + "/dev/disk/by-label/ubuntu-seed", + boot.InitramfsUbuntuSeedDir, + "--no-pager", + "--no-ask-password", + "--fsck=yes", + }, { + "systemd-mount", + filepath.Join(s.seedDir, "snaps", s.snapd.Filename()), + snapdMnt, + "--no-pager", + "--no-ask-password", + "--fsck=no", + }, { + "systemd-mount", + filepath.Join(s.seedDir, "snaps", s.kernel.Filename()), + kernelMnt, + "--no-pager", + "--no-ask-password", + "--fsck=no", + }, { + "systemd-mount", + filepath.Join(s.seedDir, "snaps", s.core20.Filename()), + baseMnt, + "--no-pager", + "--no-ask-password", + "--fsck=no", + }, { + "systemd-mount", + "tmpfs", + boot.InitramfsDataDir, + "--no-pager", + "--no-ask-password", + "--type=tmpfs", + "--fsck=no", + }, + }) +} + +func (s *initramfsMountsSuite) TestInitramfsMountsRecoverModeNoSaveHappyRealSystemdMount(c *C) { + s.mockProcCmdlineContent(c, "snapd_recovery_mode=recover snapd_recovery_system="+s.sysLabel) + + baseMnt := filepath.Join(boot.InitramfsRunMntDir, "base") + 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) + return n%2 == 0, nil + case 3, 4: + c.Assert(where, Equals, snapdMnt) + return n%2 == 0, nil + case 5, 6: + c.Assert(where, Equals, kernelMnt) + return n%2 == 0, nil + case 7, 8: + c.Assert(where, Equals, baseMnt) + return n%2 == 0, nil + case 9, 10: + c.Assert(where, Equals, boot.InitramfsDataDir) + return n%2 == 0, nil + case 11, 12: + c.Assert(where, Equals, boot.InitramfsUbuntuBootDir) + return n%2 == 0, nil + case 13, 14: + c.Assert(where, Equals, boot.InitramfsHostUbuntuDataDir) + return n%2 == 0, nil + default: + c.Errorf("unexpected IsMounted check on %s", where) + return false, fmt.Errorf("unexpected IsMounted check on %s", where) + } + }) + 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() + + 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(boot.InitramfsWritableDir) + 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.target", + "initrd-fs.target", + "initrd-switch-root.target", + "local-fs.target", + } { + for _, mountUnit := range []string{ + systemd.EscapeUnitNamePath(boot.InitramfsUbuntuSeedDir), + systemd.EscapeUnitNamePath(snapdMnt), + systemd.EscapeUnitNamePath(kernelMnt), + systemd.EscapeUnitNamePath(baseMnt), + 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] +Requires=%[1]s +After=%[1]s +`, mountUnit+".mount")) + } + } + + // 2 IsMounted calls per mount point, so 14 total IsMounted calls + c.Assert(n, Equals, 14) + + c.Assert(cmd.Calls(), DeepEquals, [][]string{ + { + "systemd-mount", + "/dev/disk/by-label/ubuntu-seed", + boot.InitramfsUbuntuSeedDir, + "--no-pager", + "--no-ask-password", + "--fsck=yes", + }, { + "systemd-mount", + filepath.Join(s.seedDir, "snaps", s.snapd.Filename()), + snapdMnt, + "--no-pager", + "--no-ask-password", + "--fsck=no", + }, { + "systemd-mount", + filepath.Join(s.seedDir, "snaps", s.kernel.Filename()), + kernelMnt, + "--no-pager", + "--no-ask-password", + "--fsck=no", + }, { + "systemd-mount", + filepath.Join(s.seedDir, "snaps", s.core20.Filename()), + baseMnt, + "--no-pager", + "--no-ask-password", + "--fsck=no", + }, { + "systemd-mount", + "tmpfs", + boot.InitramfsDataDir, + "--no-pager", + "--no-ask-password", + "--type=tmpfs", + "--fsck=no", + }, { + "systemd-mount", + "/dev/disk/by-partuuid/ubuntu-boot-partuuid", + boot.InitramfsUbuntuBootDir, + "--no-pager", + "--no-ask-password", + "--fsck=yes", + }, { + "systemd-mount", + "/dev/disk/by-partuuid/ubuntu-data-partuuid", + boot.InitramfsHostUbuntuDataDir, + "--no-pager", + "--no-ask-password", + "--fsck=no", + }, + }) + + // 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 twice, first for ubuntu-data + // unencrypted, then for ubuntu-save unencrypted + c.Assert(unlockVolumeWithSealedKeyCalls, Equals, 2) +} + +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") + 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() + + 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(boot.InitramfsWritableDir) + 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.target", + "initrd-fs.target", + "initrd-switch-root.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] +Requires=%[1]s +After=%[1]s +`, mountUnit+".mount")) + } + + c.Check(isMountedChecks, DeepEquals, []string{ + boot.InitramfsUbuntuSeedDir, + snapdMnt, + kernelMnt, + baseMnt, + boot.InitramfsDataDir, + boot.InitramfsUbuntuBootDir, + boot.InitramfsHostUbuntuDataDir, + boot.InitramfsUbuntuSaveDir, + }) + c.Check(cmd.Calls(), DeepEquals, [][]string{ + { + "systemd-mount", + "/dev/disk/by-label/ubuntu-seed", + boot.InitramfsUbuntuSeedDir, + "--no-pager", + "--no-ask-password", + "--fsck=yes", + }, { + "systemd-mount", + filepath.Join(s.seedDir, "snaps", s.snapd.Filename()), + snapdMnt, + "--no-pager", + "--no-ask-password", + "--fsck=no", + }, { + "systemd-mount", + filepath.Join(s.seedDir, "snaps", s.kernel.Filename()), + kernelMnt, + "--no-pager", + "--no-ask-password", + "--fsck=no", + }, { + "systemd-mount", + filepath.Join(s.seedDir, "snaps", s.core20.Filename()), + baseMnt, + "--no-pager", + "--no-ask-password", + "--fsck=no", + }, { + "systemd-mount", + "tmpfs", + boot.InitramfsDataDir, + "--no-pager", + "--no-ask-password", + "--type=tmpfs", + "--fsck=no", + }, { + "systemd-mount", + "/dev/disk/by-partuuid/ubuntu-boot-partuuid", + boot.InitramfsUbuntuBootDir, + "--no-pager", + "--no-ask-password", + "--fsck=yes", + }, { + "systemd-mount", + "/dev/disk/by-partuuid/ubuntu-data-partuuid", + boot.InitramfsHostUbuntuDataDir, + "--no-pager", + "--no-ask-password", + "--fsck=no", + }, { + "systemd-mount", + "/dev/disk/by-partuuid/ubuntu-save-partuuid", + boot.InitramfsUbuntuSaveDir, + "--no-pager", + "--no-ask-password", + "--fsck=no", + }, + }) + + // we should not have written a degraded.json + c.Assert(filepath.Join(dirs.SnapBootstrapRunDir, "degraded.json"), testutil.FileAbsent) +} + +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") + 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) + return n%2 == 0, nil + case 3, 4: + c.Assert(where, Equals, boot.InitramfsUbuntuSeedDir) + return n%2 == 0, nil + case 5, 6: + c.Assert(where, Equals, boot.InitramfsDataDir) + return n%2 == 0, nil + case 7, 8: + c.Assert(where, Equals, baseMnt) + return n%2 == 0, nil + case 9, 10: + c.Assert(where, Equals, kernelMnt) + return n%2 == 0, nil + default: + c.Errorf("unexpected IsMounted check on %s", where) + return false, fmt.Errorf("unexpected IsMounted check on %s", where) + } + }) + 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() + + 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(boot.InitramfsWritableDir) + 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.target", + "initrd-fs.target", + "initrd-switch-root.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(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] +Requires=%[1]s +After=%[1]s +`, mountUnit+".mount")) + } + } + + // 2 IsMounted calls per mount point, so 10 total IsMounted calls + c.Assert(n, Equals, 10) + + c.Assert(cmd.Calls(), DeepEquals, [][]string{ + { + "systemd-mount", + "/dev/disk/by-label/ubuntu-boot", + boot.InitramfsUbuntuBootDir, + "--no-pager", + "--no-ask-password", + "--fsck=yes", + }, { + "systemd-mount", + "/dev/disk/by-partuuid/ubuntu-seed-partuuid", + boot.InitramfsUbuntuSeedDir, + "--no-pager", + "--no-ask-password", + "--fsck=yes", + }, { + "systemd-mount", + "/dev/disk/by-partuuid/ubuntu-data-partuuid", + boot.InitramfsDataDir, + "--no-pager", + "--no-ask-password", + "--fsck=yes", + }, { + "systemd-mount", + filepath.Join(dirs.SnapBlobDirUnder(boot.InitramfsWritableDir), s.core20.Filename()), + baseMnt, + "--no-pager", + "--no-ask-password", + "--fsck=no", + }, { + "systemd-mount", + filepath.Join(dirs.SnapBlobDirUnder(boot.InitramfsWritableDir), s.kernel.Filename()), + kernelMnt, + "--no-pager", + "--no-ask-password", + "--fsck=no", + }, + }) +} + +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") + 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() + + 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(boot.InitramfsWritableDir) + 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.target", + "initrd-fs.target", + "initrd-switch-root.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] +Requires=%[1]s +After=%[1]s +`, mountUnit+".mount")) + } + + c.Check(isMountedChecks, DeepEquals, []string{ + boot.InitramfsUbuntuBootDir, + boot.InitramfsUbuntuSeedDir, + boot.InitramfsDataDir, + boot.InitramfsUbuntuSaveDir, + baseMnt, + kernelMnt, + }) + c.Check(cmd.Calls(), DeepEquals, [][]string{ + { + "systemd-mount", + "/dev/disk/by-label/ubuntu-boot", + boot.InitramfsUbuntuBootDir, + "--no-pager", + "--no-ask-password", + "--fsck=yes", + }, { + "systemd-mount", + "/dev/disk/by-partuuid/ubuntu-seed-partuuid", + boot.InitramfsUbuntuSeedDir, + "--no-pager", + "--no-ask-password", + "--fsck=yes", + }, { + "systemd-mount", + "/dev/disk/by-partuuid/ubuntu-data-partuuid", + boot.InitramfsDataDir, + "--no-pager", + "--no-ask-password", + "--fsck=yes", + }, { + "systemd-mount", + "/dev/disk/by-partuuid/ubuntu-save-partuuid", + boot.InitramfsUbuntuSaveDir, + "--no-pager", + "--no-ask-password", + "--fsck=yes", + }, { + "systemd-mount", + filepath.Join(dirs.SnapBlobDirUnder(boot.InitramfsWritableDir), s.core20.Filename()), + baseMnt, + "--no-pager", + "--no-ask-password", + "--fsck=no", + }, { + "systemd-mount", + filepath.Join(dirs.SnapBlobDirUnder(boot.InitramfsWritableDir), s.kernel.Filename()), + kernelMnt, + "--no-pager", + "--no-ask-password", + "--fsck=no", + }, + }) +} + +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{ + ubuntuLabelMount("ubuntu-boot", "run"), + ubuntuPartUUIDMount("ubuntu-seed-partuuid", "run"), + ubuntuPartUUIDMount("ubuntu-data-partuuid", "run"), + ubuntuPartUUIDMount("ubuntu-save-partuuid", "run"), + s.makeRunSnapSystemdMount(snap.TypeBase, s.core20), + 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() + + makeSnapFilesOnEarlyBootUbuntuData(c, s.kernel, s.core20) + + // write modeenv + modeEnv := boot.Modeenv{ + Mode: "run", + RecoverySystem: "20191118", + Base: s.core20.Filename(), + CurrentKernels: []string{s.kernel.Filename()}, + } + err := modeEnv.WriteTo(boot.InitramfsWritableDir) + 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, + }, + ubuntuPartUUIDMount("ubuntu-seed-partuuid", "run"), + ubuntuPartUUIDMount("ubuntu-data-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() + + 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(boot.InitramfsWritableDir) + 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{ + ubuntuLabelMount("ubuntu-boot", "run"), + ubuntuPartUUIDMount("ubuntu-seed-partuuid", "run"), + { + "/dev/mapper/ubuntu-data-random", + boot.InitramfsDataDir, + needsFsckDiskMountOpts, + }, + { + "/dev/mapper/ubuntu-save-random", + boot.InitramfsUbuntuSaveDir, + needsFsckDiskMountOpts, + }, + s.makeRunSnapSystemdMount(snap.TypeBase, s.core20), + 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, DeepEquals, &secboot.UnlockVolumeUsingSealedKeyOptions{ + AllowRecoveryKey: true, + }) + + dataActivated = true + // return true because we are using an encrypted device + return happyUnlocked("ubuntu-data", secboot.UnlockedWithSealedKey), nil + }) + defer restore() + + s.mockUbuntuSaveKeyAndMarker(c, boot.InitramfsWritableDir, "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() + + 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(boot.InitramfsWritableDir) + 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) TestInitramfsMountsRunModeEncryptedDataUnhappyNoSave(c *C) { + s.mockProcCmdlineContent(c, "snapd_recovery_mode=run") + + defaultEncNoSaveBootDisk := &disks.MockDiskMapping{ + FilesystemLabelToPartUUID: map[string]string{ + "ubuntu-boot": "ubuntu-boot-partuuid", + "ubuntu-seed": "ubuntu-seed-partuuid", + "ubuntu-data-enc": "ubuntu-data-enc-partuuid", + // 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{ + ubuntuLabelMount("ubuntu-boot", "run"), + ubuntuPartUUIDMount("ubuntu-seed-partuuid", "run"), + { + "/dev/mapper/ubuntu-data-random", + boot.InitramfsDataDir, + needsFsckDiskMountOpts, + }, + }, 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() + + 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(boot.InitramfsWritableDir) + 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{ + ubuntuLabelMount("ubuntu-boot", "run"), + ubuntuPartUUIDMount("ubuntu-seed-partuuid", "run"), + { + "/dev/mapper/ubuntu-data-random", + boot.InitramfsDataDir, + needsFsckDiskMountOpts, + }, + }, 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, boot.InitramfsWritableDir, "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() + + 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(boot.InitramfsWritableDir) + 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)) + + // 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") + })() + + // install and recover mounts are just ubuntu-seed before we fail + var restore func() + if mode == "run" { + // run mode will mount ubuntu-boot and ubuntu-seed + restore = s.mockSystemdMountSequence(c, []systemdMount{ + ubuntuLabelMount("ubuntu-boot", mode), + ubuntuPartUUIDMount("ubuntu-seed-partuuid", mode), + }, nil) + restore2 := disks.MockMountPointDisksToPartitionMapping( + map[disks.Mountpoint]*disks.MockDiskMapping{ + {Mountpoint: boot.InitramfsUbuntuBootDir}: defaultEncBootDisk, + }, + ) + defer restore2() + } else { + restore = s.mockSystemdMountSequence(c, []systemdMount{ + 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(), + CurrentKernels: []string{s.kernel.Filename()}, + }, + additionalMountsFunc: func() []systemdMount { + return []systemdMount{ + s.makeRunSnapSystemdMount(snap.TypeBase, s.core20), + s.makeRunSnapSystemdMount(snap.TypeKernel, s.kernel), + } + }, + enableKernel: s.kernel, + snapFiles: []snap.PlaceInfo{s.core20, s.kernel}, + comment: "happy default no upgrades", + }, + + // happy upgrade cases + { + modeenv: &boot.Modeenv{ + Mode: "run", + Base: s.core20.Filename(), + CurrentKernels: []string{s.kernel.Filename(), s.kernelr2.Filename()}, + }, + additionalMountsFunc: func() []systemdMount { + return []systemdMount{ + s.makeRunSnapSystemdMount(snap.TypeBase, s.core20), + s.makeRunSnapSystemdMount(snap.TypeKernel, s.kernelr2), + } + }, + kernelStatus: boot.TryingStatus, + enableKernel: s.kernel, + enableTryKernel: s.kernelr2, + snapFiles: []snap.PlaceInfo{s.core20, 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, + CurrentKernels: []string{s.kernel.Filename()}, + }, + additionalMountsFunc: func() []systemdMount { + return []systemdMount{ + s.makeRunSnapSystemdMount(snap.TypeBase, s.core20r2), + s.makeRunSnapSystemdMount(snap.TypeKernel, s.kernel), + } + }, + enableKernel: s.kernel, + snapFiles: []snap.PlaceInfo{s.kernel, s.core20, s.core20r2}, + expModeenv: &boot.Modeenv{ + Mode: "run", + Base: s.core20.Filename(), + TryBase: s.core20r2.Filename(), + BaseStatus: boot.TryingStatus, + 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, + CurrentKernels: []string{s.kernel.Filename(), s.kernelr2.Filename()}, + }, + additionalMountsFunc: func() []systemdMount { + return []systemdMount{ + s.makeRunSnapSystemdMount(snap.TypeBase, s.core20r2), + s.makeRunSnapSystemdMount(snap.TypeKernel, s.kernelr2), + } + }, + enableKernel: s.kernel, + enableTryKernel: s.kernelr2, + snapFiles: []snap.PlaceInfo{s.kernel, s.kernelr2, s.core20, s.core20r2}, + kernelStatus: boot.TryingStatus, + expModeenv: &boot.Modeenv{ + Mode: "run", + Base: s.core20.Filename(), + TryBase: s.core20r2.Filename(), + BaseStatus: boot.TryingStatus, + 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(), + BaseStatus: boot.TryStatus, + CurrentKernels: []string{s.kernel.Filename()}, + }, + additionalMountsFunc: func() []systemdMount { + return []systemdMount{ + s.makeRunSnapSystemdMount(snap.TypeBase, s.core20), + s.makeRunSnapSystemdMount(snap.TypeKernel, s.kernel), + } + }, + enableKernel: s.kernel, + snapFiles: []snap.PlaceInfo{s.kernel, s.core20}, + comment: "happy fallback try base not existing", + }, + { + modeenv: &boot.Modeenv{ + Mode: "run", + Base: s.core20.Filename(), + BaseStatus: boot.TryStatus, + TryBase: "", + CurrentKernels: []string{s.kernel.Filename()}, + }, + additionalMountsFunc: func() []systemdMount { + return []systemdMount{ + s.makeRunSnapSystemdMount(snap.TypeBase, s.core20), + s.makeRunSnapSystemdMount(snap.TypeKernel, s.kernel), + } + }, + enableKernel: s.kernel, + snapFiles: []snap.PlaceInfo{s.kernel, s.core20}, + 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, + CurrentKernels: []string{s.kernel.Filename()}, + }, + additionalMountsFunc: func() []systemdMount { + return []systemdMount{ + s.makeRunSnapSystemdMount(snap.TypeBase, s.core20), + s.makeRunSnapSystemdMount(snap.TypeKernel, s.kernel), + } + }, + enableKernel: s.kernel, + snapFiles: []snap.PlaceInfo{s.kernel, s.core20, s.core20r2}, + expModeenv: &boot.Modeenv{ + Mode: "run", + Base: s.core20.Filename(), + TryBase: s.core20r2.Filename(), + BaseStatus: boot.DefaultStatus, + CurrentKernels: []string{s.kernel.Filename()}, + }, + comment: "happy fallback failed boot with try snap", + }, + { + modeenv: &boot.Modeenv{ + Mode: "run", + Base: s.core20.Filename(), + CurrentKernels: []string{s.kernel.Filename()}, + }, + enableKernel: s.kernel, + enableTryKernel: s.kernelr2, + snapFiles: []snap.PlaceInfo{s.core20, 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(), + CurrentKernels: []string{s.kernel.Filename()}, + }, + kernelStatus: boot.TryingStatus, + enableKernel: s.kernel, + snapFiles: []snap.PlaceInfo{s.core20, s.kernel}, + 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: "fallback base snap unusable: cannot get snap revision: modeenv base boot variable is empty", + comment: "unhappy empty modeenv", + }, + { + modeenv: &boot.Modeenv{ + Mode: "run", + Base: s.core20.Filename(), + CurrentKernels: []string{s.kernel.Filename()}, + }, + enableKernel: s.kernelr2, + snapFiles: []snap.PlaceInfo{s.core20, s.kernelr2}, + 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) + + 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) + + restore := disks.MockMountPointDisksToPartitionMapping( + map[disks.Mountpoint]*disks.MockDiskMapping{ + {Mountpoint: boot.InitramfsUbuntuBootDir}: defaultBootDisk, + {Mountpoint: boot.InitramfsDataDir}: defaultBootDisk, + }, + ) + cleanups = append(cleanups, restore) + + // 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{ + ubuntuLabelMount("ubuntu-boot", "run"), + ubuntuPartUUIDMount("ubuntu-seed-partuuid", "run"), + 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(boot.InitramfsWritableDir) + c.Assert(err, IsNil, comment) + + // make the snap files - no restore needed because we use a unique root + // dir for each test case + 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(boot.InitramfsWritableDir) + 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) 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 = ioutil.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 = ioutil.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 +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}`) + + // 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{ + ubuntuLabelMount("ubuntu-seed", "recover"), + s.makeSeedSnapSystemdMount(snap.TypeSnapd), + s.makeSeedSnapSystemdMount(snap.TypeKernel), + s.makeSeedSnapSystemdMount(snap.TypeBase), + { + "tmpfs", + boot.InitramfsDataDir, + tmpfsMountOpts, + }, + { + "/dev/disk/by-partuuid/ubuntu-boot-partuuid", + boot.InitramfsUbuntuBootDir, + needsFsckDiskMountOpts, + }, + { + "/dev/disk/by-partuuid/ubuntu-data-partuuid", + boot.InitramfsHostUbuntuDataDir, + nil, + }, + { + "/dev/disk/by-partuuid/ubuntu-save-partuuid", + boot.InitramfsUbuntuSaveDir, + 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) 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, [][]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{ + ubuntuLabelMount("ubuntu-seed", "recover"), + s.makeSeedSnapSystemdMount(snap.TypeSnapd), + s.makeSeedSnapSystemdMount(snap.TypeKernel), + s.makeSeedSnapSystemdMount(snap.TypeBase), + { + "tmpfs", + boot.InitramfsDataDir, + tmpfsMountOpts, + }, + { + "/dev/disk/by-partuuid/ubuntu-boot-partuuid", + boot.InitramfsUbuntuBootDir, + needsFsckDiskMountOpts, + }, + { + "/dev/disk/by-partuuid/ubuntu-data-partuuid", + boot.InitramfsHostUbuntuDataDir, + nil, + }, + { + "/dev/disk/by-partuuid/ubuntu-save-partuuid", + boot.InitramfsUbuntuSaveDir, + 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(boot.InitramfsWritableDir, "_writable_defaults/etc/cloud/cloud-init.disabled")), Equals, true) + + // check that everything from the gadget defaults was setup + c.Assert(osutil.FileExists(filepath.Join(boot.InitramfsWritableDir, "_writable_defaults/etc/ssh/sshd_not_to_be_run")), Equals, true) + c.Assert(osutil.FileExists(filepath.Join(boot.InitramfsWritableDir, "_writable_defaults/var/lib/console-conf/complete")), Equals, true) + exists, _, _ := osutil.DirExists(filepath.Join(boot.InitramfsWritableDir, "_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(boot.InitramfsWritableDir, "_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, + }, + s.makeSeedSnapSystemdMount(snap.TypeSnapd), + s.makeSeedSnapSystemdMount(snap.TypeKernel), + s.makeSeedSnapSystemdMount(snap.TypeBase), + { + "tmpfs", + boot.InitramfsDataDir, + tmpfsMountOpts, + }, + { + "/dev/disk/by-partuuid/ubuntu-boot-partuuid", + boot.InitramfsUbuntuBootDir, + needsFsckDiskMountOpts, + }, + { + "/dev/disk/by-partuuid/ubuntu-data-partuuid", + boot.InitramfsHostUbuntuDataDir, + nil, + }, + { + "/dev/disk/by-partuuid/ubuntu-save-partuuid", + boot.InitramfsUbuntuSaveDir, + 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, DeepEquals, &secboot.UnlockVolumeUsingSealedKeyOptions{}) + dataActivated = true + return happyUnlocked("ubuntu-data", secboot.UnlockedWithSealedKey), nil + }) + defer restore() + + s.mockUbuntuSaveKeyAndMarker(c, boot.InitramfsHostWritableDir, "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{ + ubuntuLabelMount("ubuntu-seed", "recover"), + s.makeSeedSnapSystemdMount(snap.TypeSnapd), + s.makeSeedSnapSystemdMount(snap.TypeKernel), + s.makeSeedSnapSystemdMount(snap.TypeBase), + { + "tmpfs", + boot.InitramfsDataDir, + tmpfsMountOpts, + }, + { + "/dev/disk/by-partuuid/ubuntu-boot-partuuid", + boot.InitramfsUbuntuBootDir, + needsFsckDiskMountOpts, + }, + { + "/dev/mapper/ubuntu-data-random", + boot.InitramfsHostUbuntuDataDir, + nil, + }, + { + "/dev/mapper/ubuntu-save-random", + boot.InitramfsUbuntuSaveDir, + 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, exp map[string]interface{}) { + b, err := ioutil.ReadFile(filepath.Join(dirs.SnapBootstrapRunDir, "degraded.json")) + c.Assert(err, IsNil) + degradedJSONObj := make(map[string]interface{}, 0) + 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, DeepEquals, &secboot.UnlockVolumeUsingSealedKeyOptions{}) + 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, DeepEquals, &secboot.UnlockVolumeUsingSealedKeyOptions{ + AllowRecoveryKey: true, + }) + 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, boot.InitramfsHostWritableDir, "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{ + ubuntuLabelMount("ubuntu-seed", "recover"), + s.makeSeedSnapSystemdMount(snap.TypeSnapd), + s.makeSeedSnapSystemdMount(snap.TypeKernel), + s.makeSeedSnapSystemdMount(snap.TypeBase), + { + "tmpfs", + boot.InitramfsDataDir, + tmpfsMountOpts, + }, + { + "/dev/disk/by-partuuid/ubuntu-boot-partuuid", + boot.InitramfsUbuntuBootDir, + needsFsckDiskMountOpts, + }, + { + "/dev/mapper/ubuntu-data-random", + boot.InitramfsHostUbuntuDataDir, + nil, + }, + { + "/dev/mapper/ubuntu-save-random", + boot.InitramfsUbuntuSaveDir, + nil, + }, + }, nil) + defer restore() + + s.testRecoverModeHappy(c) + + checkDegradedJSON(c, 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, DeepEquals, &secboot.UnlockVolumeUsingSealedKeyOptions{}) + 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, DeepEquals, &secboot.UnlockVolumeUsingSealedKeyOptions{ + AllowRecoveryKey: true, + }) + 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, boot.InitramfsHostWritableDir, "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{ + ubuntuLabelMount("ubuntu-seed", "recover"), + s.makeSeedSnapSystemdMount(snap.TypeSnapd), + s.makeSeedSnapSystemdMount(snap.TypeKernel), + s.makeSeedSnapSystemdMount(snap.TypeBase), + { + "tmpfs", + boot.InitramfsDataDir, + tmpfsMountOpts, + }, + { + "/dev/disk/by-partuuid/ubuntu-boot-partuuid", + boot.InitramfsUbuntuBootDir, + needsFsckDiskMountOpts, + }, + { + "/dev/mapper/ubuntu-data-random", + boot.InitramfsHostUbuntuDataDir, + nil, + }, + { + "/dev/mapper/ubuntu-save-random", + boot.InitramfsUbuntuSaveDir, + nil, + }, + }, nil) + defer restore() + + s.testRecoverModeHappy(c) + + checkDegradedJSON(c, 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{ + FilesystemLabelToPartUUID: map[string]string{ + "ubuntu-seed": "ubuntu-seed-partuuid", + "ubuntu-data-enc": "ubuntu-data-enc-partuuid", + "ubuntu-save-enc": "ubuntu-save-enc-partuuid", + }, + 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, DeepEquals, &secboot.UnlockVolumeUsingSealedKeyOptions{ + AllowRecoveryKey: true, + }) + 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, boot.InitramfsHostWritableDir, "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{ + ubuntuLabelMount("ubuntu-seed", "recover"), + s.makeSeedSnapSystemdMount(snap.TypeSnapd), + s.makeSeedSnapSystemdMount(snap.TypeKernel), + s.makeSeedSnapSystemdMount(snap.TypeBase), + { + "tmpfs", + boot.InitramfsDataDir, + tmpfsMountOpts, + }, + // no ubuntu-boot + { + "/dev/mapper/ubuntu-data-random", + boot.InitramfsHostUbuntuDataDir, + nil, + }, + { + "/dev/mapper/ubuntu-save-random", + boot.InitramfsUbuntuSaveDir, + nil, + }, + }, nil) + defer restore() + + s.testRecoverModeHappy(c) + + checkDegradedJSON(c, 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{ + FilesystemLabelToPartUUID: map[string]string{ + "ubuntu-seed": "ubuntu-seed-partuuid", + "ubuntu-data-enc": "ubuntu-data-enc-partuuid", + "ubuntu-save-enc": "ubuntu-save-enc-partuuid", + }, + 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, DeepEquals, &secboot.UnlockVolumeUsingSealedKeyOptions{ + AllowRecoveryKey: true, + }) + 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, boot.InitramfsHostWritableDir, "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{ + ubuntuLabelMount("ubuntu-seed", "recover"), + s.makeSeedSnapSystemdMount(snap.TypeSnapd), + s.makeSeedSnapSystemdMount(snap.TypeKernel), + s.makeSeedSnapSystemdMount(snap.TypeBase), + { + "tmpfs", + boot.InitramfsDataDir, + tmpfsMountOpts, + }, + // no ubuntu-boot + { + "/dev/mapper/ubuntu-data-random", + boot.InitramfsHostUbuntuDataDir, + nil, + }, + { + "/dev/mapper/ubuntu-save-random", + boot.InitramfsUbuntuSaveDir, + nil, + }, + }, nil) + defer restore() + + s.testRecoverModeHappy(c) + + checkDegradedJSON(c, 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, DeepEquals, &secboot.UnlockVolumeUsingSealedKeyOptions{}) + 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, DeepEquals, &secboot.UnlockVolumeUsingSealedKeyOptions{ + AllowRecoveryKey: true, + }) + 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, DeepEquals, &secboot.UnlockVolumeUsingSealedKeyOptions{ + AllowRecoveryKey: true, + }) + 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, boot.InitramfsHostWritableDir, "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{ + ubuntuLabelMount("ubuntu-seed", "recover"), + s.makeSeedSnapSystemdMount(snap.TypeSnapd), + s.makeSeedSnapSystemdMount(snap.TypeKernel), + s.makeSeedSnapSystemdMount(snap.TypeBase), + { + "tmpfs", + boot.InitramfsDataDir, + tmpfsMountOpts, + }, + { + "/dev/disk/by-partuuid/ubuntu-boot-partuuid", + boot.InitramfsUbuntuBootDir, + needsFsckDiskMountOpts, + }, + { + "/dev/mapper/ubuntu-save-random", + boot.InitramfsUbuntuSaveDir, + 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.InitramfsWritableDir, "var/lib/snapd/modeenv") + c.Check(modeEnv, testutil.FileEquals, `mode=recover +recovery_system=20191118 +base=core20_1.snap +model=my-brand/my-model +grade=signed +`) + + checkDegradedJSON(c, 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) TestInitramfsMountsRecoverModeDegradedAbsentDataSaveFallbackHappy(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{ + FilesystemLabelToPartUUID: map[string]string{ + "ubuntu-boot": "ubuntu-boot-partuuid", + "ubuntu-seed": "ubuntu-seed-partuuid", + "ubuntu-save": "ubuntu-save-partuuid", + }, + 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 + 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, DeepEquals, &secboot.UnlockVolumeUsingSealedKeyOptions{}) + // sanity 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") + + case 2: + // we can however still mount unecrypted ubuntu-save + 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 + "-enc") + c.Assert(err, FitsTypeOf, disks.PartitionNotFoundError{}) + unencDevPartUUID, err := disk.FindMatchingPartitionUUIDWithFsLabel(name) + c.Assert(err, IsNil) + c.Assert(unencDevPartUUID, Equals, "ubuntu-save-partuuid") + c.Assert(opts, DeepEquals, &secboot.UnlockVolumeUsingSealedKeyOptions{ + AllowRecoveryKey: true, + }) + saveActivated = true + 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, boot.InitramfsHostWritableDir, "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{ + ubuntuLabelMount("ubuntu-seed", "recover"), + s.makeSeedSnapSystemdMount(snap.TypeSnapd), + s.makeSeedSnapSystemdMount(snap.TypeKernel), + s.makeSeedSnapSystemdMount(snap.TypeBase), + { + "tmpfs", + boot.InitramfsDataDir, + tmpfsMountOpts, + }, + { + "/dev/disk/by-partuuid/ubuntu-boot-partuuid", + boot.InitramfsUbuntuBootDir, + needsFsckDiskMountOpts, + }, + { + "/dev/disk/by-partuuid/ubuntu-save-partuuid", + boot.InitramfsUbuntuSaveDir, + 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.InitramfsWritableDir, "var/lib/snapd/modeenv") + c.Check(modeEnv, testutil.FileEquals, `mode=recover +recovery_system=20191118 +base=core20_1.snap +model=my-brand/my-model +grade=signed +`) + + checkDegradedJSON(c, 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) + 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) TestInitramfsMountsRecoverModeDegradedUnencryptedDataSaveEncryptedUnhappy(c *C) { + // test a scenario when data is unencrypted but save is encrypted + 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{ + FilesystemLabelToPartUUID: map[string]string{ + "ubuntu-boot": "ubuntu-boot-partuuid", + "ubuntu-seed": "ubuntu-seed-partuuid", + // ubuntu-data is unencrypted but ubuntu-save is encrypted + "ubuntu-data": "ubuntu-data-partuuid", + "ubuntu-save-enc": "ubuntu-save-enc-partuuid", + }, + 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() + + 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 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, DeepEquals, &secboot.UnlockVolumeUsingSealedKeyOptions{}) + // sanity 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 + + case 2: + // we can however still find/unlock 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, FitsTypeOf, disks.PartitionNotFoundError{}) + encDevPartUUID, err := disk.FindMatchingPartitionUUIDWithFsLabel(name + "-enc") + c.Assert(err, IsNil) + c.Assert(encDevPartUUID, Equals, "ubuntu-save-enc-partuuid") + c.Assert(opts, DeepEquals, &secboot.UnlockVolumeUsingSealedKeyOptions{ + AllowRecoveryKey: true, + }) + saveActivated = true + return happyUnlocked("ubuntu-save", 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, boot.InitramfsHostWritableDir, "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{ + ubuntuLabelMount("ubuntu-seed", "recover"), + s.makeSeedSnapSystemdMount(snap.TypeSnapd), + s.makeSeedSnapSystemdMount(snap.TypeKernel), + s.makeSeedSnapSystemdMount(snap.TypeBase), + { + "tmpfs", + boot.InitramfsDataDir, + tmpfsMountOpts, + }, + { + "/dev/disk/by-partuuid/ubuntu-boot-partuuid", + boot.InitramfsUbuntuBootDir, + needsFsckDiskMountOpts, + }, + { + "/dev/disk/by-partuuid/ubuntu-data-partuuid", + boot.InitramfsHostUbuntuDataDir, + 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 encryption status for disk dataUnencSaveEnc: ubuntu-data \(device /dev/disk/by-partuuid/ubuntu-data-partuuid\) was found unencrypted but ubuntu-save \(device /dev/mapper/ubuntu-save-random\) was found to be encrypted`) + + // we always need to lock access to sealed keys + c.Check(sealedKeysLocked, Equals, true) + + 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) 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) + + // no ubuntu-data on the disk at all + mockDiskNoData := &disks.MockDiskMapping{ + FilesystemLabelToPartUUID: map[string]string{ + "ubuntu-boot": "ubuntu-boot-partuuid", + "ubuntu-seed": "ubuntu-seed-partuuid", + "ubuntu-save-enc": "ubuntu-save-enc-partuuid", + }, + 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, DeepEquals, &secboot.UnlockVolumeUsingSealedKeyOptions{}) + 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, DeepEquals, &secboot.UnlockVolumeUsingSealedKeyOptions{ + AllowRecoveryKey: true, + }) + 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, boot.InitramfsHostWritableDir, "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{ + ubuntuLabelMount("ubuntu-seed", "recover"), + s.makeSeedSnapSystemdMount(snap.TypeSnapd), + s.makeSeedSnapSystemdMount(snap.TypeKernel), + s.makeSeedSnapSystemdMount(snap.TypeBase), + { + "tmpfs", + boot.InitramfsDataDir, + tmpfsMountOpts, + }, + { + "/dev/disk/by-partuuid/ubuntu-boot-partuuid", + boot.InitramfsUbuntuBootDir, + needsFsckDiskMountOpts, + }, + { + "/dev/mapper/ubuntu-save-random", + boot.InitramfsUbuntuSaveDir, + 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.InitramfsWritableDir, "var/lib/snapd/modeenv") + c.Check(modeEnv, testutil.FileEquals, `mode=recover +recovery_system=20191118 +base=core20_1.snap +model=my-brand/my-model +grade=signed +`) + + checkDegradedJSON(c, 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, DeepEquals, &secboot.UnlockVolumeUsingSealedKeyOptions{}) + 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, DeepEquals, &secboot.UnlockVolumeUsingSealedKeyOptions{ + AllowRecoveryKey: true, + }) + 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, DeepEquals, &secboot.UnlockVolumeUsingSealedKeyOptions{ + AllowRecoveryKey: true, + }) + 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, boot.InitramfsHostWritableDir, "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{ + ubuntuLabelMount("ubuntu-seed", "recover"), + s.makeSeedSnapSystemdMount(snap.TypeSnapd), + s.makeSeedSnapSystemdMount(snap.TypeKernel), + s.makeSeedSnapSystemdMount(snap.TypeBase), + { + "tmpfs", + boot.InitramfsDataDir, + tmpfsMountOpts, + }, + { + "/dev/disk/by-partuuid/ubuntu-boot-partuuid", + boot.InitramfsUbuntuBootDir, + needsFsckDiskMountOpts, + }, + }, 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 +model=my-brand/my-model +grade=signed +`) + + checkDegradedJSON(c, 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, DeepEquals, &secboot.UnlockVolumeUsingSealedKeyOptions{}) + dataActivated = true + return happyUnlocked("ubuntu-data", secboot.UnlockedWithSealedKey), nil + }) + defer restore() + + s.mockUbuntuSaveKeyAndMarker(c, boot.InitramfsHostWritableDir, "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{ + ubuntuLabelMount("ubuntu-seed", "recover"), + s.makeSeedSnapSystemdMount(snap.TypeSnapd), + s.makeSeedSnapSystemdMount(snap.TypeKernel), + s.makeSeedSnapSystemdMount(snap.TypeBase), + { + "tmpfs", + boot.InitramfsDataDir, + tmpfsMountOpts, + }, + { + "/dev/disk/by-partuuid/ubuntu-boot-partuuid", + boot.InitramfsUbuntuBootDir, + needsFsckDiskMountOpts, + }, + { + "/dev/mapper/ubuntu-data-random", + boot.InitramfsHostUbuntuDataDir, + nil, + }, + { + "/dev/mapper/ubuntu-save-random", + boot.InitramfsUbuntuSaveDir, + 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.InitramfsWritableDir, "var/lib/snapd/modeenv") + c.Check(modeEnv, testutil.FileEquals, `mode=recover +recovery_system=20191118 +base=core20_1.snap +model=my-brand/my-model +grade=signed +`) + + checkDegradedJSON(c, 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{ + FilesystemLabelToPartUUID: map[string]string{ + "ubuntu-seed": "ubuntu-seed-partuuid", + "ubuntu-boot": "ubuntu-boot-partuuid", + "ubuntu-data-enc": "ubuntu-data-enc-partuuid", + "ubuntu-save-enc": "ubuntu-save-enc-partuuid", + }, + DiskHasPartitions: true, + DevNum: "bootDev", + } + attackerDisk := &disks.MockDiskMapping{ + FilesystemLabelToPartUUID: map[string]string{ + "ubuntu-seed": "ubuntu-seed-attacker-partuuid", + "ubuntu-boot": "ubuntu-boot-attacker-partuuid", + "ubuntu-data-enc": "ubuntu-data-enc-attacker-partuuid", + "ubuntu-save-enc": "ubuntu-save-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, DeepEquals, &secboot.UnlockVolumeUsingSealedKeyOptions{}) + + activated = true + return happyUnlocked("ubuntu-data", secboot.UnlockedWithSealedKey), nil + }) + defer restore() + + s.mockUbuntuSaveKeyAndMarker(c, boot.InitramfsHostWritableDir, "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{ + ubuntuLabelMount("ubuntu-seed", "recover"), + s.makeSeedSnapSystemdMount(snap.TypeSnapd), + s.makeSeedSnapSystemdMount(snap.TypeKernel), + s.makeSeedSnapSystemdMount(snap.TypeBase), + { + "tmpfs", + boot.InitramfsDataDir, + tmpfsMountOpts, + }, + { + "/dev/disk/by-partuuid/ubuntu-boot-partuuid", + boot.InitramfsUbuntuBootDir, + needsFsckDiskMountOpts, + }, + { + "/dev/mapper/ubuntu-data-random", + boot.InitramfsHostUbuntuDataDir, + nil, + }, + { + "/dev/mapper/ubuntu-save-random", + boot.InitramfsUbuntuSaveDir, + 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{ + ubuntuLabelMount("ubuntu-seed", mode), + s.makeSeedSnapSystemdMount(snap.TypeSnapd), + s.makeSeedSnapSystemdMount(snap.TypeKernel), + s.makeSeedSnapSystemdMount(snap.TypeBase), + { + "tmpfs", + boot.InitramfsDataDir, + tmpfsMountOpts, + }, + } + + mockDiskMapping := map[disks.Mountpoint]*disks.MockDiskMapping{ + {Mountpoint: boot.InitramfsUbuntuSeedDir}: { + FilesystemLabelToPartUUID: map[string]string{ + "ubuntu-seed": "ubuntu-seed-partuuid", + }, + 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, + }, + systemdMount{ + "/dev/disk/by-partuuid/ubuntu-data-partuuid", + boot.InitramfsHostUbuntuDataDir, + nil, + }, + systemdMount{ + "/dev/disk/by-partuuid/ubuntu-save-partuuid", + boot.InitramfsUbuntuSaveDir, + 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.FilesystemLabelToPartUUID["ubuntu-boot"] = "ubuntu-boot-partuuid" + disk.FilesystemLabelToPartUUID["ubuntu-data"] = "ubuntu-data-partuuid" + disk.FilesystemLabelToPartUUID["ubuntu-save"] = "ubuntu-save-partuuid" + + // 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 +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") +} 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..56bc1123 --- /dev/null +++ b/cmd/snap-bootstrap/cmd_recovery_chooser_trigger.go @@ -0,0 +1,111 @@ +// -*- 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 + + // 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"` +} + +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 + 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.MarkerFile != "" { + markerFile = c.MarkerFile + } + logger.Noticef("trigger wait timeout %v", timeout) + logger.Noticef("marker file %v", markerFile) + + _, err := os.Stat(markerFile) + if err == nil { + logger.Noticef("marker already present") + return nil + } + + err = triggerwatchWait(timeout) + 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..010bd7af --- /dev/null +++ b/cmd/snap-bootstrap/cmd_recovery_chooser_trigger_test.go @@ -0,0 +1,165 @@ +// -*- 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" + "io/ioutil" + "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) + + restore := main.MockDefaultMarkerFile(marker) + defer restore() + restore = main.MockTriggerwatchWait(func(timeout time.Duration) error { + passedTimeout = timeout + 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(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) 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) + + restore := main.MockTriggerwatchWait(func(timeout time.Duration) error { + passedTimeout = timeout + n++ + // trigger happened + return nil + }) + defer restore() + + rest, err := main.Parser().ParseArgs([]string{ + "recovery-chooser-trigger", + "--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(marker, testutil.FilePresent) +} + +func (s *cmdSuite) TestRecoveryChooserTriggerDoesNothingWhenMarkerPresent(c *C) { + marker := filepath.Join(c.MkDir(), "foobar") + n := 0 + restore := main.MockTriggerwatchWait(func(_ time.Duration) error { + n++ + return errors.New("unexpected call") + }) + defer restore() + + err := ioutil.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) 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) TestRecoveryChooserTriggerNoInputDevsNoError(c *C) { + n := 0 + marker := filepath.Join(c.MkDir(), "marker") + + restore := main.MockDefaultMarkerFile(marker) + defer restore() + restore = main.MockTriggerwatchWait(func(_ 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..2fdb97df --- /dev/null +++ b/cmd/snap-bootstrap/degraded-recover-mode.svg @@ -0,0 +1,3 @@ + + +
start
start
start (s1)
start (s1)
success
success
fail
fail
mount boot (s2)
mount boot (s2)
fail
(encrypted)
fail...
success
success
fail
(unencrypted)
fail...
unlock data
with run key (s4)
unlock data...
success
(encrypted)
success...
fail or
success (unencrypted)
fail or...
mount data (s5)
mount data (s5)
fail
fail
success
success
unlock save
with run key (s7)
unlock save...
fail
fail
success
success
unlock save
with fallback key (s6)
unlock save...
success
success
fail
fail
unlock data with fallback key (s3)
unlock data with fal...
done
done
mount save (s8)
mount save (s8)
done (s9)
done (s9)
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..138fe1b8 --- /dev/null +++ b/cmd/snap-bootstrap/export_test.go @@ -0,0 +1,146 @@ +// -*- 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/osutil/disks" + "github.com/snapcore/snapd/secboot" +) + +var ( + Parser = parser + + DoSystemdMount = doSystemdMountImpl +) + +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 MockTimeNow(f func() time.Time) (restore func()) { + old := timeNow + timeNow = f + return func() { + timeNow = 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) error) (restore func()) { + oldTriggerwatchWait := triggerwatchWait + triggerwatchWait = f + return func() { + triggerwatchWait = oldTriggerwatchWait + } +} + +var DefaultTimeout = defaultTimeout + +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 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 + } +} diff --git a/cmd/snap-bootstrap/initramfs_mounts_state.go b/cmd/snap-bootstrap/initramfs_mounts_state.go new file mode 100644 index 00000000..b094dd3d --- /dev/null +++ b/cmd/snap-bootstrap/initramfs_mounts_state.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 main + +import ( + "errors" + "fmt" + "os" + "path/filepath" + + "github.com/snapcore/snapd/asserts" + "github.com/snapcore/snapd/boot" + "github.com/snapcore/snapd/seed" + "github.com/snapcore/snapd/snap" + "github.com/snapcore/snapd/timings" +) + +// initramfsMountsState helps tracking the state and progress +// of the mounting driving process. +type initramfsMountsState struct { + mode string + recoverySystem string +} + +var errRunModeNoImpliedRecoverySystem = errors.New("internal error: no implied recovery system in run mode") + +// ReadEssential returns the model and verified essential +// snaps from the recoverySystem. If recoverySystem is "" the +// implied one will be used (only for modes other than run). +func (mst *initramfsMountsState) ReadEssential(recoverySystem string, essentialTypes []snap.Type) (*asserts.Model, []*seed.Snap, error) { + if recoverySystem == "" { + if mst.mode == "run" { + return nil, nil, errRunModeNoImpliedRecoverySystem + } + recoverySystem = mst.recoverySystem + } + + perf := timings.New(nil) + return seed.ReadSystemEssential(boot.InitramfsUbuntuSeedDir, recoverySystem, essentialTypes, perf) +} + +// 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]snap.PlaceInfo) (*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].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..24dbaf41 --- /dev/null +++ b/cmd/snap-bootstrap/initramfs_systemd_mount.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 main + +import ( + "fmt" + "io/ioutil" + "os" + "os/exec" + "path/filepath" + "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] +Requires=%[1]s +After=%[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 +} + +// 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.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") + } + + // note that we do not currently parse any output from systemd-mount, but if + // we ever do, take special care surrounding the debug output that systemd + // outputs with the "debug" kernel command line present (or equivalently the + // SYSTEMD_LOG_LEVEL=debug env var) which will add lots of additional output + // to stderr from systemd commands + out, err := exec.Command("systemd-mount", args...).CombinedOutput() + if err != nil { + return osutil.OutputErr(out, err) + } + + // 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, we need to make the mount units depend on + // all of the various initrd special targets by adding runtime conf + // files there + // 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.target", + "initrd-fs.target", + "initrd-switch-root.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 = ioutil.WriteFile(filepath.Join(targetDir, fname), overrideContent, 0644) + if err != nil { + return 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 { + 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..fff8b5de --- /dev/null +++ b/cmd/snap-bootstrap/initramfs_systemd_mount_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 main_test + +import ( + "fmt" + "path/filepath" + "time" + + main "github.com/snapcore/snapd/cmd/snap-bootstrap" + "github.com/snapcore/snapd/dirs" + "github.com/snapcore/snapd/systemd" + "github.com/snapcore/snapd/testutil" + + . "gopkg.in/check.v1" +) + +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", + }, + } + + 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) + + args := []string{ + "systemd-mount", t.what, t.where, "--no-pager", "--no-ask-password", + } + if opts.Tmpfs { + args = append(args, "--type=tmpfs") + } + if opts.NeedsFsck { + args = append(args, "--fsck=yes") + } else { + args = append(args, "--fsck=no") + } + if opts.NoWait { + args = append(args, "--no-block") + } + c.Assert(cmd.Calls(), DeepEquals, [][]string{args}) + + // 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.target", + "initrd-fs.target", + "initrd-switch-root.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] +Requires=%[1]s +After=%[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..0dc00b8d --- /dev/null +++ b/cmd/snap-bootstrap/main.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 + +import ( + "fmt" + "os" + + "github.com/jessevdk/go-flags" + + "github.com/snapcore/snapd/logger" + + // this import will init "secboot.FDEHasRevealKey" correctly + _ "github.com/snapcore/snapd/overlord/devicestate/fde" +) + +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 init() { + err := logger.SimpleSetup() + if err != nil { + fmt.Fprintf(os.Stderr, "WARNING: failed to activate logging: %s\n", err) + } +} + +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.SimpleSetup() + return parseArgs(args) +} + +func parseArgs(args []string) error { + p := parser() + + _, err := p.ParseArgs(args) + 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..a8f055ac --- /dev/null +++ b/cmd/snap-bootstrap/triggerwatch/evdev.go @@ -0,0 +1,244 @@ +// -*- 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: 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 (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 + + kc, ok := strToKey[filter.Key] + if !ok { + return nil, fmt.Errorf("cannot find a key matching the filter %q", filter.Key) + } + cap := evdev.CapabilityCode{Code: kc, Name: filter.Key} + + match := func(dev *evdev.InputDevice) triggerDevice { + for _, cc := range dev.Capabilities[evKeyCapability] { + if cc == cap { + return &evdevKeyboardInputDevice{ + dev: dev, + keyCode: uint16(cap.Code), + } + } + } + return nil + } + // collect all input devices that can emit the trigger key + var devs []triggerDevice + for _, dev := range devices { + idev := match(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..1cad33c2 --- /dev/null +++ b/cmd/snap-bootstrap/triggerwatch/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 triggerwatch + +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 diff --git a/cmd/snap-bootstrap/triggerwatch/triggerwatch.go b/cmd/snap-bootstrap/triggerwatch/triggerwatch.go new file mode 100644 index 00000000..0528871d --- /dev/null +++ b/cmd/snap-bootstrap/triggerwatch/triggerwatch.go @@ -0,0 +1,87 @@ +// -*- 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" + "time" + + "github.com/snapcore/snapd/logger" +) + +type triggerProvider interface { + FindMatchingDevices(filter triggerEventFilter) ([]triggerDevice, error) +} + +type triggerDevice interface { + WaitForTrigger(chan keyEvent) + String() string + Close() +} + +var ( + // trigger mechanism + trigger triggerProvider + + // 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) error { + 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 { + return ErrNoMatchingInputDevices + } + + 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() + } + + select { + case kev := <-detectKeyCh: + if kev.Err != nil { + return err + } + // channel got closed without an error + logger.Noticef("%s: + got trigger key %v", kev.Dev, triggerFilter.Key) + case <-time.After(timeout): + return ErrTriggerNotDetected + } + + return nil +} diff --git a/cmd/snap-bootstrap/triggerwatch/triggerwatch_test.go b/cmd/snap-bootstrap/triggerwatch/triggerwatch_test.go new file mode 100644 index 00000000..a9b698c3 --- /dev/null +++ b/cmd/snap-bootstrap/triggerwatch/triggerwatch_test.go @@ -0,0 +1,130 @@ +// -*- 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 ( + "fmt" + "testing" + "time" + + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/cmd/snap-bootstrap/triggerwatch" +) + +// 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 { + waitForTriggerCalls int + closeCalls int + ev *triggerwatch.KeyEvent +} + +func (m *mockTriggerDevice) WaitForTrigger(n chan triggerwatch.KeyEvent) { + 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++ } + +type mockTrigger struct { + f triggerwatch.TriggerCapabilityFilter + d *mockTriggerDevice + err error + + findMatchingCalls 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 +} + +const testTriggerTimeout = 5 * 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) + 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) + c.Assert(err, Equals, triggerwatch.ErrTriggerNotDetected) + c.Assert(mi.findMatchingCalls, Equals, 1) + 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) + 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) + 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) }, + Panics, "trigger is unset") +} diff --git a/cmd/snap-confine/PORTING b/cmd/snap-confine/PORTING new file mode 100644 index 00000000..43869489 --- /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 configufation +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..2968e1f2 --- /dev/null +++ b/cmd/snap-confine/mount-support-nvidia.c @@ -0,0 +1,606 @@ +/* + * 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" + +#define SC_NVIDIA_DRIVER_VERSION_FILE "/sys/module/nvidia/version" + +// note: if the parent dir changes to something other than +// the current /var/lib/snapd/lib then sc_mkdir_and_mount_and_bind +// and sc_mkdir_and_mount_and_bind need updating. +#define SC_LIB "/var/lib/snapd/lib" +#define SC_LIBGL_DIR SC_LIB "/gl" +#define SC_LIBGL32_DIR SC_LIB "/gl32" +#define SC_VULKAN_DIR SC_LIB "/vulkan" +#define SC_GLVND_DIR SC_LIB "/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.so*", + "libEGL_nvidia.so*", + "libGL.so*", + "libOpenGL.so*", + "libGLESv1_CM.so*", + "libGLESv1_CM_nvidia.so*", + "libGLESv2.so*", + "libGLESv2_nvidia.so*", + "libGLX_indirect.so*", + "libGLX_nvidia.so*", + "libGLX.so*", + "libGLdispatch.so*", + "libGLU.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-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_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; + +#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 *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 + 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, nvidia_globs, + nvidia_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, + nvidia_globs, nvidia_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 *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, nvidia_globs, + nvidia_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, + nvidia_globs, + nvidia_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) +{ + /* 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 = mkdir(SC_LIB, 0755); + if (res != 0 && errno != EEXIST) { + die("cannot create " SC_LIB); + } + if (res == 0 && (chown(SC_LIB, 0, 0) < 0)) { + // Adjust the ownership only if we created the directory. + die("cannot change ownership of " SC_LIB); + } + (void)sc_set_effective_identity(old); +#ifdef NVIDIA_MULTIARCH + sc_mount_nvidia_driver_multiarch(rootfs_dir); +#endif // ifdef NVIDIA_MULTIARCH +#ifdef NVIDIA_BIARCH + sc_mount_nvidia_driver_biarch(rootfs_dir); +#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..56ec893f --- /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); + +#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..751873f9 --- /dev/null +++ b/cmd/snap-confine/mount-support-test.c @@ -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 . + * + */ + +#include "mount-support.h" +#include "mount-support.c" +#include "mount-support-nvidia.h" +#include "mount-support-nvidia.c" + +#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..7f4a80a5 --- /dev/null +++ b/cmd/snap-confine/mount-support.c @@ -0,0 +1,820 @@ +/* + * 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 "../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 + +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_mount(const char *snap_name) +{ + // 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_dir[MAX_BUF] = { 0 }; + char tmp_dir[MAX_BUF] = { 0 }; + int base_dir_fd SC_CLEANUP(sc_cleanup_close) = -1; + int tmp_dir_fd SC_CLEANUP(sc_cleanup_close) = -1; + sc_must_snprintf(base_dir, sizeof(base_dir), "/tmp/snap.%s", snap_name); + sc_must_snprintf(tmp_dir, sizeof(tmp_dir), "%s/tmp", base_dir); + + /* 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()); + // Create /tmp/snap.$SNAP_NAME/ 0700 root.root. Ignore EEXIST since we want + // to reuse and we will open with O_NOFOLLOW, below. + if (mkdir(base_dir, 0700) < 0 && errno != EEXIST) { + die("cannot create base directory %s", base_dir); + } + base_dir_fd = open(base_dir, + O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW); + if (base_dir_fd < 0) { + die("cannot open base directory %s", base_dir); + } + /* This seems redundant on first read but it has the non-obvious + * property of changing existing directories that have already existed + * but had incorrect ownership or permission. This is possible due to + * earlier bugs in snap-confine and due to the fact that some systems + * use persistent /tmp directory and may not clean up leftover files + * for arbitrarily long. This comment applies the following two pairs + * of fchmod and fchown. */ + if (fchmod(base_dir_fd, 0700) < 0) { + die("cannot chmod base directory %s to 0700", base_dir); + } + if (fchown(base_dir_fd, 0, 0) < 0) { + die("cannot chown base directory %s to root.root", base_dir); + } + // Create /tmp/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_dir); + } + (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_dir); + } + if (fchmod(tmp_dir_fd, 01777) < 0) { + die("cannot chmod private tmp directory %s/tmp to 01777", + base_dir); + } + if (fchown(tmp_dir_fd, 0, 0) < 0) { + die("cannot chown private tmp directory %s/tmp to root.root", + base_dir); + } + 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 we are in legacy mode + // which 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, 0); +} + +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; + sc_distro distro; + bool normal_mode; + const char *base_snap_name; +}; + +/** + * 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); + // 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 it can either be applied to the root + // filesystem of a core system (aka all-snap) or the core snap on a classic + // system. In the former case we need recursive bind mounts 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); + // 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 = config->mounts; 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); + } + } + 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 + const char *dirs_from_core[] = { + "/etc/alternatives", "/etc/ssl", "/etc/nsswitch.conf", + // Some specifc 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", + NULL + }; + for (const char **dirs = dirs_from_core; *dirs != NULL; dirs++) { + const char *dir = *dirs; + 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); + } + // Create the hostfs directory if one is missing. This directory is a part + // of packaging now so perhaps this code can be removed later. + sc_identity old = sc_set_effective_identity(sc_root_group_identity()); + if (mkdir(SC_HOSTFS_DIR, 0755) < 0) { + if (errno != EEXIST) { + die("cannot perform operation: mkdir %s", SC_HOSTFS_DIR); + } + } + (void)sc_set_effective_identity(old); + // Ensure that hostfs isgroup 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 visilbe on disk. + // This was LP:#1665004 + struct stat sb; + if (stat(SC_HOSTFS_DIR, &sb) < 0) { + die("cannot stat %s", SC_HOSTFS_DIR); + } + if (sb.st_uid != 0 || sb.st_gid != 0) { + if (chown(SC_HOSTFS_DIR, 0, 0) < 0) { + die("cannot change user/group owner of %s to root", + 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); + } + // 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; +} + +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. + const struct sc_mount mounts[] = { + {"/dev"}, // because it contains devices on host OS + {"/etc"}, // because that's where /etc/resolv.conf lives, perhaps a bad idea + {"/home"}, // to support /home/*/snap and home interface + {"/root"}, // because that is $HOME for services + {"/proc"}, // fundamental filesystem + {"/sys"}, // fundamental filesystem + {"/tmp"}, // to get writable tmp + {"/var/snap"}, // to get access to global snap data + {"/var/lib/snapd"}, // to get access to snapd state and seccomp profiles + {"/var/tmp"}, // to get access to the other temporary directory + {"/run"}, // to get /run with sockets and what not + {"/lib/modules",.is_optional = true}, // access to the modules of the running kernel + {"/lib/firmware",.is_optional = true}, // access to the firmware of the running kernel + {"/usr/src"}, // FIXME: move to SecurityMounts in system-trace interface + {"/var/log"}, // FIXME: move to SecurityMounts in log-observe interface +#ifdef MERGED_USR + {"/run/media", true, "/media"}, // access to the users removable devices +#else + {"/media", true}, // access to the users removable devices +#endif // MERGED_USR + {"/run/netns", 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. + {"/mnt",.is_optional = true}, // to support the removable-media interface + {"/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, + .distro = distro, + .normal_mode = true, + .base_snap_name = inv->base_snap_name, + }; + sc_bootstrap_mount_namespace(&normal_config); + } else { + // In legacy mode we don't pivot and instead just arrange bi- + // directional mount propagation for two directories. + const struct sc_mount mounts[] = { + {"/media", true}, + {"/run/netns", true}, + {}, + }; + struct sc_mount_config legacy_config = { + .rootfs_dir = "/", + .mounts = mounts, + .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_mount(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, 0); + 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, 0); + 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, 0); + + /* 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, 0); +} diff --git a/cmd/snap-confine/mount-support.h b/cmd/snap-confine/mount-support.h new file mode 100644 index 00000000..b2a96f3b --- /dev/null +++ b/cmd/snap-confine/mount-support.h @@ -0,0 +1,75 @@ +/* + * 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 + +/** + * 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..3e99521b --- /dev/null +++ b/cmd/snap-confine/ns-support.c @@ -0,0 +1,917 @@ +/* + * 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 should_discard_current_ns(dev_t base_snap_dev) +{ + // Inspect the namespace and check if we should discard it. + // + // 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. + sc_mountinfo_entry *mie; + 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"); + } + for (mie = sc_first_mountinfo_entry(mi); mie != NULL; + mie = sc_next_mountinfo_entry(mie)) { + if (!sc_streq(mie->mount_dir, "/")) { + continue; + } + // NOTE: we want the initial rootfs just in case overmount + // was used to do something weird. The initial rootfs was + // set up by snap-confine and that is the one we want to + // measure. + debug("block device of the root filesystem is %d:%d", + mie->dev_major, mie->dev_minor); + return base_snap_dev != makedev(mie->dev_major, mie->dev_minor); + } + die("cannot find mount entry of the root filesystem"); +} + +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); +} + +// 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(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_cgroup_is_v2()) { + debug + ("WARNING: cgroup v2 detected, preserved mount namespace process presence check unsupported, discarding"); + break; + } + if (sc_cgroup_freezer_occupied(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); +} diff --git a/cmd/snap-confine/ns-support.h b/cmd/snap-confine/ns-support.h new file mode 100644 index 00000000..1adfe21e --- /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 and returns ESRCH. If the mount namespace was joined the + * function 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.c b/cmd/snap-confine/seccomp-support.c new file mode 100644 index 00000000..7432d79c --- /dev/null +++ b/cmd/snap-confine/seccomp-support.c @@ -0,0 +1,193 @@ +/* + * 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 "../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; + +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); + } +} + +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 }; + sc_must_snprintf(profile_path, sizeof(profile_path), "%s/%s.bin", + 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; + } + for (long i = 0; i < max_wait; ++i) { + if (access(profile_path, F_OK) == 0) { + break; + } + sleep(1); + } + + // 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); + + /* The extra space has dual purpose. First of all, it is required to detect + * feof() while still being able to correctly read MAX_BPF_SIZE bytes of + * seccomp profile. In addition, because we treat the profile as a + * quasi-string and use sc_streq(), to compare it. The extra space is used + * as a way to ensure the result is a terminated string (though in practice + * it can contain embedded NULs any earlier position). Note that + * sc_read_seccomp_filter knows about the extra space and ensures that the + * buffer is never empty. */ + char bpf[MAX_BPF_SIZE + 1] = { 0 }; + size_t num_read = sc_read_seccomp_filter(profile_path, bpf, sizeof bpf); + if (sc_streq(bpf, "@unrestricted\n")) { + return false; + } + struct sock_fprog prog = { + .len = num_read / sizeof(struct sock_filter), + .filter = (struct sock_filter *)bpf, + }; + sc_apply_seccomp_filter(&prog); + 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..9ed9b0aa --- /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 ".bin". 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..344a3444 --- /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) */ + 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..a44c9ce8 --- /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..f927b1cf --- /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..01f2747f --- /dev/null +++ b/cmd/snap-confine/snap-confine-invocation.c @@ -0,0 +1,146 @@ +/* + * 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); + } +} + +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); +} diff --git a/cmd/snap-confine/snap-confine-invocation.h b/cmd/snap-confine/snap-confine-invocation.h new file mode 100644 index 00000000..e4839683 --- /dev/null +++ b/cmd/snap-confine/snap-confine-invocation.h @@ -0,0 +1,80 @@ +/* + * 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; + 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); + +#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..dd262c9c --- /dev/null +++ b/cmd/snap-confine/snap-confine.apparmor.in @@ -0,0 +1,578 @@ +# Author: Jamie Strandboge +#include + +@LIBEXECDIR@/snap-confine (attach_disconnected) { + # Include any additional files that snapd chose to generate. + # - for $HOME on NFS + # - 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, + /{,usr/}lib{,32,64,x32}/{,@{multiarch}/{,atomics/}}ld-*.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, + + /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, + + # 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, + + # 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 + /{tmp/snap.rootfs_*/,}var/lib/snapd/seccomp/bpf/*.bin r, + + # 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 + 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/, + 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/, + 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 a whitelisted 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. + audit deny /tmp/snap.rootfs_*/{var/,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.*/ rw, + /tmp/snap.*/tmp/ rw, + mount options=(rw private) -> /tmp/, + mount options=(rw bind) /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, + + # 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/** mrixwlk, + # new-style encrypted $HOME + @{HOMEDIRS}/.ecryptfs/*/.Private/ r, + @{HOMEDIRS}/.ecryptfs/*/.Private/** mrixwlk, + + # 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-*.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..a9f59cf8 --- /dev/null +++ b/cmd/snap-confine/snap-confine.c @@ -0,0 +1,755 @@ +/* + * 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 "../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/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]; + char orig_cwd[PATH_MAX]; + 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); + } + + /* 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"); +} + +/** + * 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) +{ + // 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. + die("snap-confine has elevated permissions and is not confined" + " but should be. Refusing to continue to avoid" + " permission escalation attacks"); + } + + /* 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); + } + // 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); + 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); + } +} + +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); + + /** Conditionally create, populate and join the device cgroup. */ + sc_setup_device_cgroup(inv->security_tag); + + /** + * 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"); + + /* 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 do not 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"); + } + } + } + // 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. + 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..1e78eca9 --- /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/snap-confine/+filebug diff --git a/cmd/snap-confine/snap-device-helper b/cmd/snap-confine/snap-device-helper new file mode 100755 index 00000000..cc0d4abe --- /dev/null +++ b/cmd/snap-confine/snap-device-helper @@ -0,0 +1,81 @@ +#!/bin/sh +# udev callout to allow a snap to access a device node +set -e +# debugging +#exec >>/tmp/snap-device-helper.log +#exec 2>&1 +#set -x +# end debugging + +ACTION="$1" +APPNAME="$2" +DEVPATH="$3" +MAJMIN="$4" +[ -n "$APPNAME" ] || { echo "no app name given" >&2; exit 1; } +[ -n "$DEVPATH" ] || { echo "no devpath given" >&2; exit 1; } +[ -n "$MAJMIN" ] || { echo "no major/minor given" >&2; exit 0; } + +NOSNAP="${APPNAME#snap_}" +[ "$NOSNAP" != "$APPNAME" ] || { echo "malformed appname $APPNAME" >&2; exit 1; } + +# FIXME: this will break for instances that are called "hook" :( +# Handle hooks first, the nosnap part looks like this: +# - "$snap_hook_$hookname" +# - "$snap_$instance_hook_$hookname +# we need to make sure we change this to: +# - "$snap_hook.$hookname" +# - "$snap_$instance_hook.$hookname" +if [ -z "${NOSNAP##*_hook_hook_*}" ]; then + # $instance is 'hook'; $snap_hook_hook.$hookname -> $snap_hook_hook.$hookname + NOSNAP="${NOSNAP%_hook_*}_hook.${NOSNAP#*_hook_hook_}" +elif [ -z "${NOSNAP##*_hook_*}" ]; then + # $snap_$instance_hook_$hookname -> $snap_$instance_hook.$hookname + NOSNAP="${NOSNAP%_hook_*}_hook.${NOSNAP#*_hook_}" +fi + +# Now deal with app/instance untangling +if [ "${NOSNAP#*_*_}" = "${NOSNAP}" ]; then + # snap__ -> snap.. + SNAPAPP="snap.${NOSNAP%_*}.${NOSNAP#*_}" +else + # snap___ -> snap._. + SNAPAPP="snap.${NOSNAP%_*}.${NOSNAP#*_*_}" +fi + +DEVICES_CGROUP=${DEVICES_CGROUP:="/sys/fs/cgroup/devices"} +app_dev_cgroup="$DEVICES_CGROUP/$SNAPAPP" + +# The cgroup is only present after snap start so ignore any cgroup changes +# (eg, 'add' on boot, hotplug, hotunplug) when the cgroup doesn't exist +# yet. LP: #1762182. +if [ ! -e "$app_dev_cgroup" ]; then + exit 0 +fi + +# check if it's a block or char dev +# TODO: re-write this to be more robust, the bash variable substitution done +# here is quite awkard :-/ +if [ "${DEVPATH#*/block/}" != "$DEVPATH" ]; then + type="b" +elif [ "${DEVPATH#*/nvme/nvme*/nvme*n*}" != "$DEVPATH" ]; then + # char devices are .../nvme/nvme* but block devices are + # .../nvme/nvme*/nvme*n* and .../nvme/nvme*/nvme*n*p* + # so if have a device that has nvme/nvme*/nvme*n* in it, + # treat it as a block device + type="b" +else + type="c" +fi + +acl="$type $MAJMIN rwm" +case "$ACTION" in + add|change) + echo "$acl" > "$app_dev_cgroup/devices.allow" + ;; + remove) + echo "$acl" > "$app_dev_cgroup/devices.deny" + ;; + *) + echo "ERROR: unknown action $ACTION" >&2 + exit 1 ;; +esac diff --git a/cmd/snap-confine/snap-device-helper-test.c b/cmd/snap-confine/snap-device-helper-test.c new file mode 100644 index 00000000..3fccd0d8 --- /dev/null +++ b/cmd/snap-confine/snap-device-helper-test.c @@ -0,0 +1,261 @@ +/* + * 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 "../libsnap-confine-private/test-utils.h" + +#include +#include +#include +#include +#include +#include +#include + +// TODO: build at runtime +static const char *sdh_path_default = "snap-confine/snap-device-helper"; + +// A variant of unsetenv that is compatible with GDestroyNotify +static void my_unsetenv(const char *k) +{ + g_unsetenv(k); +} + +// A variant of rm_rf_tmp that calls g_free() on its parameter +static void rm_rf_tmp_free(gchar * dirpath) +{ + rm_rf_tmp(dirpath); + g_free(dirpath); +} + +static gchar *find_sdh_path(void) +{ + const char *sdh_from_env = g_getenv("SNAP_DEVICE_HELPER"); + if (sdh_from_env != NULL) { + return g_strdup(sdh_from_env); + } + return g_strdup(sdh_path_default); +} + +static int run_sdh(gchar * action, + gchar * appname, gchar * devpath, gchar * majmin) +{ + gchar *mod_appname = g_strdup(appname); + gchar *sdh_path = find_sdh_path(); + + // appnames have the following format: + // - snap.. + // - snap._. + // snap-device-helper expects: + // - snap__ + // - snap___ + for (size_t i = 0; i < strlen(mod_appname); i++) { + if (mod_appname[i] == '.') { + mod_appname[i] = '_'; + } + } + g_debug("appname modified from %s to %s", appname, mod_appname); + + GError *err = NULL; + + GPtrArray *argv = g_ptr_array_new(); + g_ptr_array_add(argv, sdh_path); + g_ptr_array_add(argv, action); + g_ptr_array_add(argv, mod_appname); + g_ptr_array_add(argv, devpath); + g_ptr_array_add(argv, majmin); + g_ptr_array_add(argv, NULL); + + int status = 0; + + gboolean ret = g_spawn_sync(NULL, (gchar **) argv->pdata, NULL, 0, + NULL, NULL, NULL, NULL, &status, &err); + g_free(mod_appname); + g_free(sdh_path); + g_ptr_array_unref(argv); + + if (!ret) { + g_debug("failed with: %s", err->message); + g_error_free(err); + return -2; + } + + return WEXITSTATUS(status); +} + +struct sdh_test_data { + char *action; + // snap.foo.bar + char *app; + // snap_foo_bar + char *mangled_appname; + char *file_with_data; + char *file_with_no_data; +}; + +static void test_sdh_action(gconstpointer test_data) +{ + struct sdh_test_data *td = (struct sdh_test_data *)test_data; + + gchar *mock_dir = g_dir_make_tmp(NULL, NULL); + g_assert_nonnull(mock_dir); + g_test_queue_destroy((GDestroyNotify) rm_rf_tmp_free, mock_dir); + + gchar *app_dir = g_build_filename(mock_dir, td->app, NULL); + gchar *with_data = g_build_filename(mock_dir, + td->app, + td->file_with_data, + NULL); + gchar *without_data = g_build_filename(mock_dir, + td->app, + td->file_with_no_data, + NULL); + gchar *data = NULL; + + g_assert(g_mkdir_with_parents(app_dir, 0755) == 0); + g_free(app_dir); + + g_debug("mock cgroup dir: %s", mock_dir); + + g_setenv("DEVICES_CGROUP", mock_dir, TRUE); + + g_test_queue_destroy((GDestroyNotify) my_unsetenv, "DEVICES_CGROUP"); + + int ret = + run_sdh(td->action, td->app, "/devices/foo/block/sda/sda4", "8:4"); + g_assert_cmpint(ret, ==, 0); + g_assert_true(g_file_get_contents(with_data, &data, NULL, NULL)); + g_assert_cmpstr(data, ==, "b 8:4 rwm\n"); + g_clear_pointer(&data, g_free); + g_assert(g_remove(with_data) == 0); + + g_assert_false(g_file_get_contents(without_data, &data, NULL, NULL)); + + ret = + run_sdh(td->action, td->mangled_appname, "/devices/foo/tty/ttyS0", + "4:64"); + g_assert_cmpint(ret, ==, 0); + g_assert_true(g_file_get_contents(with_data, &data, NULL, NULL)); + g_assert_cmpstr(data, ==, "c 4:64 rwm\n"); + g_clear_pointer(&data, g_free); + g_assert(g_remove(with_data) == 0); + + g_assert_false(g_file_get_contents(without_data, &data, NULL, NULL)); + + g_free(with_data); + g_free(without_data); +} + +static void test_sdh_err(void) +{ + // missing appname + int ret = run_sdh("add", "", "/devices/foo/block/sda/sda4", "8:4"); + g_assert_cmpint(ret, ==, 1); + // malformed appname + ret = run_sdh("add", "foo_bar", "/devices/foo/block/sda/sda4", "8:4"); + g_assert_cmpint(ret, ==, 1); + // missing devpath + ret = run_sdh("add", "snap_foo_bar", "", "8:4"); + g_assert_cmpint(ret, ==, 1); + // missing device major:minor numbers + ret = run_sdh("add", "snap_foo_bar", "/devices/foo/block/sda/sda4", ""); + g_assert_cmpint(ret, ==, 0); + + // mock some stuff so that we can reach the 'action' checks + gchar *mock_dir = g_dir_make_tmp(NULL, NULL); + g_assert_nonnull(mock_dir); + g_test_queue_destroy((GDestroyNotify) rm_rf_tmp_free, mock_dir); + + gchar *app_dir = g_build_filename(mock_dir, "snap.foo.bar", NULL); + g_assert(g_mkdir_with_parents(app_dir, 0755) == 0); + g_free(app_dir); + g_setenv("DEVICES_CGROUP", mock_dir, TRUE); + + g_test_queue_destroy((GDestroyNotify) my_unsetenv, "DEVICES_CGROUP"); + + ret = + run_sdh("badaction", "snap_foo_bar", "/devices/foo/block/sda/sda4", + "8:4"); + g_assert_cmpint(ret, ==, 1); +} + +static struct sdh_test_data add_data = + { "add", "snap.foo.bar", "snap_foo_bar", "devices.allow", "devices.deny" }; +static struct sdh_test_data change_data = + { "change", "snap.foo.bar", "snap_foo_bar", "devices.allow", + "devices.deny" +}; + +static struct sdh_test_data remove_data = + { "remove", "snap.foo.bar", "snap_foo_bar", "devices.deny", + "devices.allow" +}; + +static struct sdh_test_data instance_add_data = + { "add", "snap.foo_bar.baz", "snap_foo_bar_baz", "devices.allow", + "devices.deny" +}; + +static struct sdh_test_data instance_change_data = + { "change", "snap.foo_bar.baz", "snap_foo_bar_baz", "devices.allow", + "devices.deny" +}; + +static struct sdh_test_data instance_remove_data = + { "remove", "snap.foo_bar.baz", "snap_foo_bar_baz", "devices.deny", + "devices.allow" +}; + +static struct sdh_test_data add_hook_data = + { "add", "snap.foo.hook.configure", "snap_foo_hook_configure", + "devices.allow", "devices.deny" +}; + +static struct sdh_test_data instance_add_hook_data = + { "add", "snap.foo_bar.hook.configure", "snap_foo_bar_hook_configure", + "devices.allow", "devices.deny" +}; + +static struct sdh_test_data instance_add_instance_name_is_hook_data = + { "add", "snap.foo_hook.hook.configure", "snap_foo_hook_hook_configure", + "devices.allow", "devices.deny" +}; + +static void __attribute__((constructor)) init(void) +{ + + g_test_add_data_func("/snap-device-helper/add", + &add_data, test_sdh_action); + g_test_add_data_func("/snap-device-helper/change", &change_data, + test_sdh_action); + g_test_add_data_func("/snap-device-helper/remove", &remove_data, + test_sdh_action); + g_test_add_func("/snap-device-helper/err", test_sdh_err); + g_test_add_data_func("/snap-device-helper/parallel/add", + &instance_add_data, test_sdh_action); + g_test_add_data_func("/snap-device-helper/parallel/change", + &instance_change_data, test_sdh_action); + g_test_add_data_func("/snap-device-helper/parallel/remove", + &instance_remove_data, test_sdh_action); + // hooks + g_test_add_data_func("/snap-device-helper/hook/add", + &add_hook_data, test_sdh_action); + g_test_add_data_func("/snap-device-helper/hook/parallel/add", + &instance_add_hook_data, test_sdh_action); + g_test_add_data_func("/snap-device-helper/hook-name-hook/parallel/add", + &instance_add_instance_name_is_hook_data, + test_sdh_action); +} 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..036ae9ce --- /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, -ubuntu-16.04-32] +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..50ccdabd --- /dev/null +++ b/cmd/snap-confine/udev-support.c @@ -0,0 +1,453 @@ +/* + * 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 "../libsnap-confine-private/cleanup-funcs.h" +#include "../libsnap-confine-private/snap.h" +#include "../libsnap-confine-private/string-utils.h" +#include "../libsnap-confine-private/cgroup-support.h" +#include "../libsnap-confine-private/utils.h" +#include "udev-support.h" + +__attribute__((format(printf, 2, 3))) +static void sc_dprintf(int fd, const char *format, ...); + +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); +} + +/* Allow access to common devices. */ +static void sc_udev_allow_common(int devices_allow_fd) +{ + /* The devices we add here have static number allocation. + * https://www.kernel.org/doc/html/v4.11/admin-guide/devices.html */ + sc_dprintf(devices_allow_fd, "c 1:3 rwm\n"); // /dev/null + sc_dprintf(devices_allow_fd, "c 1:5 rwm\n"); // /dev/zero + sc_dprintf(devices_allow_fd, "c 1:7 rwm\n"); // /dev/full + sc_dprintf(devices_allow_fd, "c 1:8 rwm\n"); // /dev/random + sc_dprintf(devices_allow_fd, "c 1:9 rwm\n"); // /dev/urandom + sc_dprintf(devices_allow_fd, "c 5:0 rwm\n"); // /dev/tty + sc_dprintf(devices_allow_fd, "c 5:1 rwm\n"); // /dev/console + sc_dprintf(devices_allow_fd, "c 5:2 rwm\n"); // /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(int devices_allow_fd) +{ + for (unsigned pty_major = 136; pty_major <= 143; pty_major++) { + sc_dprintf(devices_allow_fd, "c %u:* rwm\n", pty_major); + } +} + +/** 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(int devices_allow_fd) +{ + 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_dprintf(devices_allow_fd, "c %u:%u rwm\n", + major(sbuf.st_rdev), minor(sbuf.st_rdev)); + } + + if (stat("/dev/nvidiactl", &sbuf) == 0) { + sc_dprintf(devices_allow_fd, "c %u:%u rwm\n", + major(sbuf.st_rdev), minor(sbuf.st_rdev)); + } + if (stat("/dev/nvidia-uvm", &sbuf) == 0) { + sc_dprintf(devices_allow_fd, "c %u:%u rwm\n", + major(sbuf.st_rdev), minor(sbuf.st_rdev)); + } + if (stat("/dev/nvidia-modeset", &sbuf) == 0) { + sc_dprintf(devices_allow_fd, "c %u:%u rwm\n", + 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(int devices_allow_fd) +{ + struct stat sbuf; + + if (stat("/dev/uhid", &sbuf) == 0) { + sc_dprintf(devices_allow_fd, "c %u:%u rwm\n", + 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(int devices_allow_fd) +{ + struct stat sbuf; + + if (stat("/dev/net/tun", &sbuf) == 0) { + sc_dprintf(devices_allow_fd, "c %u:%u rwm\n", + 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(int devices_allow_fd, struct udev *udev, + struct udev_list_entry *assigned) +{ + 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; + } + 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); + continue; + } + /* 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"); + continue; + } + debug("inspecting type of device: %s", devnode); + struct stat file_info; + if (stat(devnode, &file_info) < 0) { + debug("cannot stat %s", devnode); + continue; + } + switch (file_info.st_mode & S_IFMT) { + case S_IFBLK: + dprintf(devices_allow_fd, "b %u:%u rwm\n", major, + minor); + break; + case S_IFCHR: + dprintf(devices_allow_fd, "c %u:%u rwm\n", major, + minor); + break; + default: + /* Not a device, ignore it. */ + break; + } + udev_device_unref(device); + } +} + +static void sc_udev_setup_acls(int devices_allow_fd, int devices_deny_fd, + struct udev *udev, + struct udev_list_entry *assigned) +{ + /* 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(devices_deny_fd, "a"); + + /* Allow access to various devices. */ + sc_udev_allow_common(devices_allow_fd); + sc_udev_allow_pty_slaves(devices_allow_fd); + sc_udev_allow_nvidia(devices_allow_fd); + sc_udev_allow_uhid(devices_allow_fd); + sc_udev_allow_dev_net_tun(devices_allow_fd); + sc_udev_allow_assigned(devices_allow_fd, udev, assigned); +} + +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; + } +} + +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_udev_open_cgroup_v1(const char *security_tag) +{ + /* 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 fds = { -1, -1, -1 }; + + /* 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); + } + + /* 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); + } + + /* Open snap.$SNAP_NAME.$APP_NAME relative to /sys/fs/cgroup/devices, + * creating the directory if necessary. Note that we always chown the + * resulting directory to root:root. */ + const char *security_tag_relpath = security_tag; + sc_identity old = sc_set_effective_identity(sc_root_group_identity()); + if (mkdirat(devices_fd, security_tag_relpath, 0755) < 0) { + if (errno != EEXIST) { + die("cannot create directory %s/%s/%s", cgroup_path, + devices_relpath, security_tag_relpath); + } + } + (void)sc_set_effective_identity(old); + + 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) { + die("cannot open %s/%s/%s", cgroup_path, devices_relpath, + security_tag_relpath); + } + + /* Open devices.allow relative to /sys/fs/cgroup/devices/snap.$SNAP_NAME.$APP_NAME */ + const char *devices_allow_relpath = "devices.allow"; + int SC_CLEANUP(sc_cleanup_close) devices_allow_fd = -1; + devices_allow_fd = openat(security_tag_fd, devices_allow_relpath, + O_WRONLY | O_CLOEXEC | O_NOFOLLOW); + if (devices_allow_fd < 0) { + die("cannot open %s/%s/%s/%s", cgroup_path, devices_relpath, + security_tag_relpath, devices_allow_relpath); + } + + /* Open devices.deny relative to /sys/fs/cgroup/devices/snap.$SNAP_NAME.$APP_NAME */ + const char *devices_deny_relpath = "devices.deny"; + int SC_CLEANUP(sc_cleanup_close) devices_deny_fd = -1; + devices_deny_fd = openat(security_tag_fd, devices_deny_relpath, + O_WRONLY | O_CLOEXEC | O_NOFOLLOW); + if (devices_deny_fd < 0) { + die("cannot open %s/%s/%s/%s", cgroup_path, devices_relpath, + security_tag_relpath, devices_deny_relpath); + } + + /* Open cgroup.procs relative to /sys/fs/cgroup/devices/snap.$SNAP_NAME.$APP_NAME */ + const char *cgroup_procs_relpath = "cgroup.procs"; + int SC_CLEANUP(sc_cleanup_close) cgroup_procs_fd = -1; + cgroup_procs_fd = openat(security_tag_fd, cgroup_procs_relpath, + O_WRONLY | O_CLOEXEC | O_NOFOLLOW); + if (cgroup_procs_fd < 0) { + die("cannot open %s/%s/%s/%s", cgroup_path, devices_relpath, + security_tag_relpath, cgroup_procs_relpath); + } + + /* 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 fds; +} + +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); + } +} + +void sc_setup_device_cgroup(const char *security_tag) +{ + debug("setting up device cgroup"); + if (sc_cgroup_is_v2()) { + /* TODO: add support for v2 mode. This is coming but needs several more + * rounds of iteration. */ + return; + } + + /* 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) { + /* 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; + } + + /* Note that -1 is the neutral value for a file descriptor. + * The cleanup function associated with this variable closes + * descriptors other than -1. */ + sc_cgroup_fds SC_CLEANUP(sc_cleanup_cgroup_fds) fds = { -1, -1, -1 }; + fds = sc_udev_open_cgroup_v1(security_tag); + if (fds.cgroup_procs_fd < 0) { + die("cannot prepare cgroup v1 device hierarchy"); + return; + } + /* Setup the device group access control list */ + sc_udev_setup_acls(fds.devices_allow_fd, fds.devices_deny_fd, + udev, assigned); + + /* Move ourselves to the device cgroup */ + sc_dprintf(fds.cgroup_procs_fd, "%i\n", getpid()); + debug("associated snap application process %i with device cgroup %s", + getpid(), security_tag); +} diff --git a/cmd/snap-confine/udev-support.h b/cmd/snap-confine/udev-support.h new file mode 100644 index 00000000..b0085827 --- /dev/null +++ b/cmd/snap-confine/udev-support.h @@ -0,0 +1,23 @@ +/* + * 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 + +void sc_setup_device_cgroup(const char *security_tag); + +#endif diff --git a/cmd/snap-confine/user-support.c b/cmd/snap-confine/user-support.c new file mode 100644 index 00000000..c9115af3 --- /dev/null +++ b/cmd/snap-confine/user-support.c @@ -0,0 +1,71 @@ +/* + * 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 && !sc_startswith(user_data, "/home/")) { + // clear errno or it will be displayed in die() + errno = 0; + die("Sorry, home directories outside of /home are not currently supported. \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-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..8dc27cd3 --- /dev/null +++ b/cmd/snap-exec/export_test.go @@ -0,0 +1,60 @@ +// -*- 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 + +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 + } +} diff --git a/cmd/snap-exec/main.go b/cmd/snap-exec/main.go new file mode 100644 index 00000000..e64ad8ca --- /dev/null +++ b/cmd/snap-exec/main.go @@ -0,0 +1,272 @@ +// -*- 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/osutil" + "github.com/snapcore/snapd/snap" + "github.com/snapcore/snapd/snap/snapenv" +) + +// for the tests +var syscallExec = syscall.Exec +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) {} +} + +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) + } + + // 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...) + + 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..fee692f1 --- /dev/null +++ b/cmd/snap-exec/main_test.go @@ -0,0 +1,623 @@ +// -*- 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/ioutil" + "os" + "os/exec" + "path/filepath" + "testing" + + . "gopkg.in/check.v1" + + "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" + + snapExec "github.com/snapcore/snapd/cmd/snap-exec" +) + +// 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") +} + +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 := ioutil.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..625860b1 --- /dev/null +++ b/cmd/snap-failure/cmd_snapd.go @@ -0,0 +1,211 @@ +// -*- 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" + "io/ioutil" + "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 := ioutil.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 +) + +// FIXME: also do error reporting via errtracker +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") + 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 + output, err := exec.Command("systemctl", "stop", "snapd.socket").CombinedOutput() + if err != nil { + return osutil.OutputErr(output, err) + } + + logger.Noticef("restoring invoking snapd from: %v", snapdPath) + // 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..c7fbe626 --- /dev/null +++ b/cmd/snap-failure/cmd_snapd_test.go @@ -0,0 +1,417 @@ +// -*- 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" + "io/ioutil" + "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 = ioutil.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)}, + }) + + // 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", "") + 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 = ioutil.WriteFile(dirs.SnapdSocket, nil, 0755) + c.Assert(err, IsNil) + err = ioutil.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) 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 = ioutil.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 = ioutil.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-gdb-shim/snap-gdb-shim.c b/cmd/snap-gdb-shim/snap-gdb-shim.c new file mode 100644 index 00000000..6705393e --- /dev/null +++ b/cmd/snap-gdb-shim/snap-gdb-shim.c @@ -0,0 +1,49 @@ +/* + * 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("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..61538995 --- /dev/null +++ b/cmd/snap-gdb-shim/snap-gdbserver-shim.c @@ -0,0 +1,62 @@ +/* + * 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("THIS OPTION IS EXPERIMENTAL\n\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("- (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 100644 index 00000000..c76e6726 --- /dev/null +++ b/cmd/snap-mgmt/snap-mgmt.sh.in @@ -0,0 +1,209 @@ +#!/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" + if systemctl is-active -q "$unit"; then + echo "Stopping $unit" + systemctl stop -q "$unit" || true + fi +} + +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 + + # 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 + if grep -q " $mp $mp" /proc/self/mountinfo; then + umount -l "$mp" || true + fi + done + + 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 ' ') + for unit in $services $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 + + 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" + # 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" + 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 -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..181074f5 --- /dev/null +++ b/cmd/snap-preseed/export_test.go @@ -0,0 +1,66 @@ +// -*- 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/seed" +) + +var ( + Run = run + SystemSnapFromSeed = systemSnapFromSeed + ChooseTargetSnapdVersion = chooseTargetSnapdVersion +) + +func MockOsGetuid(f func() int) (restore func()) { + oldOsGetuid := osGetuid + osGetuid = f + return func() { osGetuid = oldOsGetuid } +} + +func MockSyscallChroot(f func(string) error) (restore func()) { + oldSyscallChroot := syscallChroot + syscallChroot = f + return func() { syscallChroot = oldSyscallChroot } +} + +func MockSnapdMountPath(path string) (restore func()) { + oldMountPath := snapdMountPath + snapdMountPath = path + return func() { snapdMountPath = oldMountPath } +} + +func MockSystemSnapFromSeed(f func(rootDir string) (string, error)) (restore func()) { + oldSystemSnapFromSeed := systemSnapFromSeed + systemSnapFromSeed = f + return func() { systemSnapFromSeed = oldSystemSnapFromSeed } +} + +func MockSeedOpen(f func(rootDir, label string) (seed.Seed, error)) (restore func()) { + oldSeedOpen := seedOpen + seedOpen = f + return func() { + seedOpen = oldSeedOpen + } +} + +func SnapdPathAndVersion(targetSnapd *targetSnapdInfo) (string, string) { + return targetSnapd.path, targetSnapd.version +} diff --git a/cmd/snap-preseed/main.go b/cmd/snap-preseed/main.go new file mode 100644 index 00000000..5109eb28 --- /dev/null +++ b/cmd/snap-preseed/main.go @@ -0,0 +1,119 @@ +// -*- 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" + + // for SanitizePlugsSlots + "github.com/snapcore/snapd/interfaces/builtin" + "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"` +} + +var ( + osGetuid = os.Getuid + Stdout io.Writer = os.Stdout + Stderr io.Writer = os.Stderr + + 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 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) error { + // real validation of plugs and slots; needs to be set + // for processing of seeds with gadget because of readInfo(). + snap.SanitizePlugsSlots = builtin.SanitizePlugsSlots + + if osGetuid() != 0 { + return fmt.Errorf("must be run as root") + } + + rest, err := parser.ParseArgs(args) + if err != nil { + return err + } + + 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 opts.Reset { + return resetPreseededChroot(chrootDir) + } + + if err := checkChroot(chrootDir); err != nil { + return err + } + + targetSnapd, cleanup, err := prepareChroot(chrootDir) + if err != nil { + return err + } + + // executing inside the chroot + err = runPreseedMode(chrootDir, targetSnapd) + cleanup() + return err +} diff --git a/cmd/snap-preseed/main_test.go b/cmd/snap-preseed/main_test.go new file mode 100644 index 00000000..e2e3c2c1 --- /dev/null +++ b/cmd/snap-preseed/main_test.go @@ -0,0 +1,705 @@ +// -*- 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 ( + "fmt" + "io/ioutil" + "os" + "path/filepath" + "strings" + + "testing" + + "github.com/jessevdk/go-flags" + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/asserts" + "github.com/snapcore/snapd/asserts/assertstest" + "github.com/snapcore/snapd/cmd/snap-preseed" + "github.com/snapcore/snapd/cmd/snaplock/runinhibit" + "github.com/snapcore/snapd/dirs" + "github.com/snapcore/snapd/osutil" + "github.com/snapcore/snapd/osutil/squashfs" + apparmor_sandbox "github.com/snapcore/snapd/sandbox/apparmor" + "github.com/snapcore/snapd/seed" + "github.com/snapcore/snapd/snap" + "github.com/snapcore/snapd/testutil" + "github.com/snapcore/snapd/timings" +) + +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 mockVersionFiles(c *C, rootDir1, version1, rootDir2, version2 string) { + versions := []string{version1, version2} + for i, root := range []string{rootDir1, rootDir2} { + c.Assert(os.MkdirAll(filepath.Join(root, dirs.CoreLibExecDir), 0755), IsNil) + infoFile := filepath.Join(root, dirs.CoreLibExecDir, "info") + c.Assert(ioutil.WriteFile(infoFile, []byte(fmt.Sprintf("VERSION=%s", versions[i])), 0644), IsNil) + } +} + +func mockChrootDirs(c *C, rootDir string, apparmorDir bool) func() { + if apparmorDir { + c.Assert(os.MkdirAll(filepath.Join(rootDir, "/sys/kernel/security/apparmor"), 0755), IsNil) + } + mockMountInfo := `912 920 0:57 / ${rootDir}/proc rw,nosuid,nodev,noexec,relatime - proc proc rw +914 913 0:7 / ${rootDir}/sys/kernel/security rw,nosuid,nodev,noexec,relatime master:8 - securityfs securityfs rw +915 920 0:58 / ${rootDir}/dev rw,relatime - tmpfs none rw,size=492k,mode=755,uid=100000,gid=100000 +` + return osutil.MockMountInfo(strings.Replace(mockMountInfo, "${rootDir}", rootDir, -1)) +} + +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) TestChrootDoesntExist(c *C) { + restore := main.MockOsGetuid(func() int { return 0 }) + defer restore() + + parser := testParser(c) + c.Check(main.Run(parser, []string{"/non-existing-dir"}), ErrorMatches, `cannot verify "/non-existing-dir": is not a directory`) +} + +func (s *startPreseedSuite) TestChrootValidationUnhappy(c *C) { + restore := main.MockOsGetuid(func() int { return 0 }) + defer restore() + + tmpDir := c.MkDir() + defer osutil.MockMountInfo("")() + + parser := testParser(c) + c.Check(main.Run(parser, []string{tmpDir}), ErrorMatches, "cannot preseed without the following mountpoints:\n - .*/dev\n - .*/proc\n - .*/sys/kernel/security") +} + +func (s *startPreseedSuite) TestChrootValidationUnhappyNoApparmor(c *C) { + restore := main.MockOsGetuid(func() int { return 0 }) + defer restore() + + tmpDir := c.MkDir() + defer mockChrootDirs(c, tmpDir, false)() + + parser := testParser(c) + c.Check(main.Run(parser, []string{tmpDir}), ErrorMatches, `cannot preseed without access to ".*sys/kernel/security/apparmor"`) +} + +func (s *startPreseedSuite) TestChrootValidationAlreadyPreseeded(c *C) { + restore := main.MockOsGetuid(func() int { return 0 }) + defer restore() + + tmpDir := c.MkDir() + snapdDir := filepath.Dir(dirs.SnapStateFile) + c.Assert(os.MkdirAll(filepath.Join(tmpDir, snapdDir), 0755), IsNil) + c.Assert(ioutil.WriteFile(filepath.Join(tmpDir, dirs.SnapStateFile), nil, os.ModePerm), IsNil) + + parser := testParser(c) + c.Check(main.Run(parser, []string{tmpDir}), ErrorMatches, fmt.Sprintf("the system at %q appears to be preseeded, pass --reset flag to clean it up", tmpDir)) +} + +func (s *startPreseedSuite) TestChrootFailure(c *C) { + restoreOsGuid := main.MockOsGetuid(func() int { return 0 }) + defer restoreOsGuid() + + restoreSyscallChroot := main.MockSyscallChroot(func(path string) error { + return fmt.Errorf("FAIL: %s", path) + }) + defer restoreSyscallChroot() + + tmpDir := c.MkDir() + defer mockChrootDirs(c, tmpDir, true)() + + parser := testParser(c) + c.Check(main.Run(parser, []string{tmpDir}), ErrorMatches, fmt.Sprintf("cannot chroot into %s: FAIL: %s", tmpDir, tmpDir)) +} + +func (s *startPreseedSuite) TestRunPreseedHappy(c *C) { + tmpDir := c.MkDir() + dirs.SetRootDir(tmpDir) + defer mockChrootDirs(c, tmpDir, true)() + + restoreOsGuid := main.MockOsGetuid(func() int { return 0 }) + defer restoreOsGuid() + + restoreSyscallChroot := main.MockSyscallChroot(func(path string) error { return nil }) + defer restoreSyscallChroot() + + mockMountCmd := testutil.MockCommand(c, "mount", "") + defer mockMountCmd.Restore() + + mockUmountCmd := testutil.MockCommand(c, "umount", "") + defer mockUmountCmd.Restore() + + targetSnapdRoot := filepath.Join(tmpDir, "target-core-mounted-here") + restoreMountPath := main.MockSnapdMountPath(targetSnapdRoot) + defer restoreMountPath() + + restoreSystemSnapFromSeed := main.MockSystemSnapFromSeed(func(string) (string, error) { return "/a/core.snap", nil }) + defer restoreSystemSnapFromSeed() + + mockTargetSnapd := testutil.MockCommand(c, filepath.Join(targetSnapdRoot, "usr/lib/snapd/snapd"), `#!/bin/sh + if [ "$SNAPD_PRESEED" != "1" ]; then + exit 1 + fi +`) + defer mockTargetSnapd.Restore() + + mockSnapdFromDeb := testutil.MockCommand(c, filepath.Join(tmpDir, "usr/lib/snapd/snapd"), `#!/bin/sh + exit 1 +`) + defer mockSnapdFromDeb.Restore() + + // snapd from the snap is newer than deb + mockVersionFiles(c, targetSnapdRoot, "2.44.0", tmpDir, "2.41.0") + + parser := testParser(c) + c.Check(main.Run(parser, []string{tmpDir}), IsNil) + + c.Assert(mockMountCmd.Calls(), HasLen, 1) + // note, tmpDir, targetSnapdRoot are contactenated again cause we're not really chrooting in the test + // and mocking dirs.RootDir + c.Check(mockMountCmd.Calls()[0], DeepEquals, []string{"mount", "-t", "squashfs", "-o", "ro,x-gdu.hide", "/a/core.snap", filepath.Join(tmpDir, targetSnapdRoot)}) + + c.Assert(mockTargetSnapd.Calls(), HasLen, 1) + c.Check(mockTargetSnapd.Calls()[0], DeepEquals, []string{"snapd"}) + + c.Assert(mockSnapdFromDeb.Calls(), HasLen, 0) + + // relative chroot path works too + tmpDirPath, relativeChroot := filepath.Split(tmpDir) + pwd, err := os.Getwd() + c.Assert(err, IsNil) + defer func() { + os.Chdir(pwd) + }() + c.Assert(os.Chdir(tmpDirPath), IsNil) + c.Check(main.Run(parser, []string{relativeChroot}), IsNil) +} + +func (s *startPreseedSuite) TestRunPreseedHappyDebVersionIsNewer(c *C) { + tmpDir := c.MkDir() + dirs.SetRootDir(tmpDir) + defer mockChrootDirs(c, tmpDir, true)() + + restoreOsGuid := main.MockOsGetuid(func() int { return 0 }) + defer restoreOsGuid() + + restoreSyscallChroot := main.MockSyscallChroot(func(path string) error { return nil }) + defer restoreSyscallChroot() + + mockMountCmd := testutil.MockCommand(c, "mount", "") + defer mockMountCmd.Restore() + + mockUmountCmd := testutil.MockCommand(c, "umount", "") + defer mockUmountCmd.Restore() + + targetSnapdRoot := filepath.Join(tmpDir, "target-core-mounted-here") + restoreMountPath := main.MockSnapdMountPath(targetSnapdRoot) + defer restoreMountPath() + + restoreSystemSnapFromSeed := main.MockSystemSnapFromSeed(func(string) (string, error) { return "/a/core.snap", nil }) + defer restoreSystemSnapFromSeed() + + c.Assert(os.MkdirAll(filepath.Join(targetSnapdRoot, "usr/lib/snapd/"), 0755), IsNil) + mockSnapdFromSnap := testutil.MockCommand(c, filepath.Join(targetSnapdRoot, "usr/lib/snapd/snapd"), `#!/bin/sh + exit 1 +`) + defer mockSnapdFromSnap.Restore() + + mockSnapdFromDeb := testutil.MockCommand(c, filepath.Join(tmpDir, "usr/lib/snapd/snapd"), `#!/bin/sh + if [ "$SNAPD_PRESEED" != "1" ]; then + exit 1 + fi +`) + defer mockSnapdFromDeb.Restore() + + // snapd from the deb is newer than snap + mockVersionFiles(c, targetSnapdRoot, "2.44.0", tmpDir, "2.45.0") + + parser := testParser(c) + c.Check(main.Run(parser, []string{tmpDir}), IsNil) + + c.Assert(mockMountCmd.Calls(), HasLen, 1) + // note, tmpDir, targetSnapdRoot are contactenated again cause we're not really chrooting in the test + // and mocking dirs.RootDir + c.Check(mockMountCmd.Calls()[0], DeepEquals, []string{"mount", "-t", "squashfs", "-o", "ro,x-gdu.hide", "/a/core.snap", filepath.Join(tmpDir, targetSnapdRoot)}) + + c.Assert(mockSnapdFromDeb.Calls(), HasLen, 1) + c.Check(mockSnapdFromDeb.Calls()[0], DeepEquals, []string{"snapd"}) + c.Assert(mockSnapdFromSnap.Calls(), HasLen, 0) +} + +type Fake16Seed struct { + AssertsModel *asserts.Model + Essential []*seed.Snap + LoadMetaErr error + LoadAssertionsErr error + UsesSnapd bool +} + +// Fake implementation of seed.Seed interface + +func mockClassicModel() *asserts.Model { + headers := map[string]interface{}{ + "type": "model", + "authority-id": "brand", + "series": "16", + "brand-id": "brand", + "model": "classicbaz-3000", + "classic": "true", + "timestamp": "2018-01-01T08:00:00+00:00", + } + return assertstest.FakeAssertion(headers, nil).(*asserts.Model) +} + +func (fs *Fake16Seed) LoadAssertions(db asserts.RODatabase, commitTo func(*asserts.Batch) error) error { + return fs.LoadAssertionsErr +} + +func (fs *Fake16Seed) Model() *asserts.Model { + return fs.AssertsModel +} + +func (fs *Fake16Seed) Brand() (*asserts.Account, error) { + headers := map[string]interface{}{ + "type": "account", + "account-id": "brand", + "display-name": "fake brand", + "username": "brand", + "timestamp": "2018-01-01T08:00:00+00:00", + } + return assertstest.FakeAssertion(headers, nil).(*asserts.Account), nil +} + +func (fs *Fake16Seed) LoadMeta(tm timings.Measurer) error { + return fs.LoadMetaErr +} + +func (fs *Fake16Seed) UsesSnapdSnap() bool { + return fs.UsesSnapd +} + +func (fs *Fake16Seed) EssentialSnaps() []*seed.Snap { + return fs.Essential +} + +func (fs *Fake16Seed) ModeSnaps(mode string) ([]*seed.Snap, error) { + return nil, nil +} + +func (s *startPreseedSuite) TestSystemSnapFromSeed(c *C) { + tmpDir := c.MkDir() + + restore := main.MockSeedOpen(func(rootDir, label string) (seed.Seed, error) { + return &Fake16Seed{ + AssertsModel: mockClassicModel(), + Essential: []*seed.Snap{{Path: "/some/path/core", SideInfo: &snap.SideInfo{RealName: "core"}}}, + }, nil + }) + defer restore() + + path, err := main.SystemSnapFromSeed(tmpDir) + c.Assert(err, IsNil) + c.Check(path, Equals, "/some/path/core") +} + +func (s *startPreseedSuite) TestSystemSnapFromSnapdSeed(c *C) { + tmpDir := c.MkDir() + + restore := main.MockSeedOpen(func(rootDir, label string) (seed.Seed, error) { + return &Fake16Seed{ + AssertsModel: mockClassicModel(), + Essential: []*seed.Snap{{Path: "/some/path/snapd.snap", SideInfo: &snap.SideInfo{RealName: "snapd"}}}, + UsesSnapd: true, + }, nil + }) + defer restore() + + path, err := main.SystemSnapFromSeed(tmpDir) + c.Assert(err, IsNil) + c.Check(path, Equals, "/some/path/snapd.snap") +} + +func (s *startPreseedSuite) TestSystemSnapFromSeedOpenError(c *C) { + tmpDir := c.MkDir() + + restore := main.MockSeedOpen(func(rootDir, label string) (seed.Seed, error) { return nil, fmt.Errorf("fail") }) + defer restore() + + _, err := main.SystemSnapFromSeed(tmpDir) + c.Assert(err, ErrorMatches, "fail") +} + +func (s *startPreseedSuite) TestSystemSnapFromSeedErrors(c *C) { + tmpDir := c.MkDir() + + fakeSeed := &Fake16Seed{} + fakeSeed.AssertsModel = mockClassicModel() + + restore := main.MockSeedOpen(func(rootDir, label string) (seed.Seed, error) { return fakeSeed, nil }) + defer restore() + + fakeSeed.Essential = []*seed.Snap{{Path: "", SideInfo: &snap.SideInfo{RealName: "core"}}} + _, err := main.SystemSnapFromSeed(tmpDir) + c.Assert(err, ErrorMatches, "core snap not found") + + fakeSeed.Essential = []*seed.Snap{{Path: "/some/path", SideInfo: &snap.SideInfo{RealName: "foosnap"}}} + _, err = main.SystemSnapFromSeed(tmpDir) + c.Assert(err, ErrorMatches, "core snap not found") + + fakeSeed.LoadMetaErr = fmt.Errorf("load meta failed") + _, err = main.SystemSnapFromSeed(tmpDir) + c.Assert(err, ErrorMatches, "load meta failed") + + fakeSeed.LoadMetaErr = nil + fakeSeed.LoadAssertionsErr = fmt.Errorf("load assertions failed") + _, err = main.SystemSnapFromSeed(tmpDir) + c.Assert(err, ErrorMatches, "load assertions failed") +} + +func (s *startPreseedSuite) TestClassicRequired(c *C) { + tmpDir := c.MkDir() + + headers := map[string]interface{}{ + "type": "model", + "authority-id": "brand", + "series": "16", + "brand-id": "brand", + "model": "baz-3000", + "architecture": "armhf", + "gadget": "brand-gadget", + "kernel": "kernel", + "timestamp": "2018-01-01T08:00:00+00:00", + } + + fakeSeed := &Fake16Seed{} + fakeSeed.AssertsModel = assertstest.FakeAssertion(headers, nil).(*asserts.Model) + + restore := main.MockSeedOpen(func(rootDir, label string) (seed.Seed, error) { return fakeSeed, nil }) + defer restore() + + _, err := main.SystemSnapFromSeed(tmpDir) + c.Assert(err, ErrorMatches, "preseeding is only supported on classic systems") +} + +func (s *startPreseedSuite) TestRunPreseedUnsupportedVersion(c *C) { + tmpDir := c.MkDir() + dirs.SetRootDir(tmpDir) + c.Assert(os.MkdirAll(filepath.Join(tmpDir, "usr/lib/snapd/"), 0755), IsNil) + defer mockChrootDirs(c, tmpDir, true)() + + restoreOsGuid := main.MockOsGetuid(func() int { return 0 }) + defer restoreOsGuid() + + restoreSyscallChroot := main.MockSyscallChroot(func(path string) error { return nil }) + defer restoreSyscallChroot() + + mockMountCmd := testutil.MockCommand(c, "mount", "") + defer mockMountCmd.Restore() + + targetSnapdRoot := filepath.Join(tmpDir, "target-core-mounted-here") + restoreMountPath := main.MockSnapdMountPath(targetSnapdRoot) + defer restoreMountPath() + + restoreSystemSnapFromSeed := main.MockSystemSnapFromSeed(func(string) (string, error) { return "/a/core.snap", nil }) + defer restoreSystemSnapFromSeed() + + c.Assert(os.MkdirAll(filepath.Join(targetSnapdRoot, "usr/lib/snapd/"), 0755), IsNil) + mockTargetSnapd := testutil.MockCommand(c, filepath.Join(targetSnapdRoot, "usr/lib/snapd/snapd"), "") + defer mockTargetSnapd.Restore() + + infoFile := filepath.Join(targetSnapdRoot, dirs.CoreLibExecDir, "info") + c.Assert(ioutil.WriteFile(infoFile, []byte("VERSION=2.43.0"), 0644), IsNil) + + // simulate snapd version from the deb + infoFile = filepath.Join(filepath.Join(tmpDir, dirs.CoreLibExecDir, "info")) + c.Assert(ioutil.WriteFile(infoFile, []byte("VERSION=2.41.0"), 0644), IsNil) + + parser := testParser(c) + c.Check(main.Run(parser, []string{tmpDir}), ErrorMatches, + `snapd 2.43.0 from the target system does not support preseeding, the minimum required version is 2.43.3\+`) +} + +func (s *startPreseedSuite) TestChooseTargetSnapdVersion(c *C) { + tmpDir := c.MkDir() + dirs.SetRootDir(tmpDir) + c.Assert(os.MkdirAll(filepath.Join(tmpDir, "usr/lib/snapd/"), 0755), IsNil) + + targetSnapdRoot := filepath.Join(tmpDir, "target-core-mounted-here") + c.Assert(os.MkdirAll(filepath.Join(targetSnapdRoot, "usr/lib/snapd/"), 0755), IsNil) + restoreMountPath := main.MockSnapdMountPath(targetSnapdRoot) + defer restoreMountPath() + + var versions = []struct { + fromSnap string + fromDeb string + expectedPath string + expectedVersion string + expectedErr string + }{ + { + fromDeb: "2.44.0", + fromSnap: "2.45.3+git123", + // snap version wins + expectedVersion: "2.45.3+git123", + expectedPath: filepath.Join(tmpDir, "target-core-mounted-here/usr/lib/snapd/snapd"), + }, + { + fromDeb: "2.44.0", + fromSnap: "2.44.0", + // snap version wins + expectedVersion: "2.44.0", + expectedPath: filepath.Join(tmpDir, "target-core-mounted-here/usr/lib/snapd/snapd"), + }, + { + fromDeb: "2.45.1+20.04", + fromSnap: "2.45.1", + // deb version wins + expectedVersion: "2.45.1+20.04", + expectedPath: filepath.Join(tmpDir, "usr/lib/snapd/snapd"), + }, + { + fromDeb: "2.45.2", + fromSnap: "2.45.1", + // deb version wins + expectedVersion: "2.45.2", + expectedPath: filepath.Join(tmpDir, "usr/lib/snapd/snapd"), + }, + { + fromSnap: "2.45.1", + expectedErr: fmt.Sprintf("cannot open snapd info file %q.*", filepath.Join(tmpDir, "usr/lib/snapd/info")), + }, + { + fromDeb: "2.45.1", + expectedErr: fmt.Sprintf("cannot open snapd info file %q.*", filepath.Join(tmpDir, "target-core-mounted-here/usr/lib/snapd/info")), + }, + } + + for _, test := range versions { + infoFile := filepath.Join(tmpDir, "usr/lib/snapd/info") + os.Remove(infoFile) + if test.fromDeb != "" { + c.Assert(ioutil.WriteFile(infoFile, []byte(fmt.Sprintf("VERSION=%s", test.fromDeb)), 0644), IsNil) + } + infoFile = filepath.Join(targetSnapdRoot, "usr/lib/snapd/info") + os.Remove(infoFile) + if test.fromSnap != "" { + c.Assert(ioutil.WriteFile(infoFile, []byte(fmt.Sprintf("VERSION=%s", test.fromSnap)), 0644), IsNil) + } + + targetSnapd, err := main.ChooseTargetSnapdVersion() + if test.expectedErr != "" { + c.Assert(err, ErrorMatches, test.expectedErr) + } else { + c.Assert(err, IsNil) + c.Assert(targetSnapd, NotNil) + path, version := main.SnapdPathAndVersion(targetSnapd) + c.Check(path, Equals, test.expectedPath) + c.Check(version, Equals, test.expectedVersion) + } + } +} + +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) TestReset(c *C) { + restore := main.MockOsGetuid(func() int { return 0 }) + defer restore() + + startDir, err := os.Getwd() + c.Assert(err, IsNil) + defer func() { + os.Chdir(startDir) + }() + + for _, isRelative := range []bool{false, true} { + tmpDir := c.MkDir() + resetDirArg := tmpDir + if isRelative { + var parentDir string + parentDir, resetDirArg = filepath.Split(tmpDir) + os.Chdir(parentDir) + } + + // mock some preseeding artifacts + artifacts := []struct { + path string + // if symlinkTarget is not empty, then a path -> symlinkTarget symlink + // will be created instead of a regular file. + symlinkTarget string + }{ + {dirs.SnapStateFile, ""}, + {dirs.SnapSystemKeyFile, ""}, + {filepath.Join(dirs.SnapDesktopFilesDir, "foo.desktop"), ""}, + {filepath.Join(dirs.SnapDesktopIconsDir, "foo.png"), ""}, + {filepath.Join(dirs.SnapMountPolicyDir, "foo.fstab"), ""}, + {filepath.Join(dirs.SnapBlobDir, "foo.snap"), ""}, + {filepath.Join(dirs.SnapUdevRulesDir, "foo-snap.bar.rules"), ""}, + {filepath.Join(dirs.SnapDBusSystemPolicyDir, "snap.foo.bar.conf"), ""}, + {filepath.Join(dirs.SnapDBusSessionServicesDir, "org.example.Session.service"), ""}, + {filepath.Join(dirs.SnapDBusSystemServicesDir, "org.example.System.service"), ""}, + {filepath.Join(dirs.SnapServicesDir, "snap.foo.service"), ""}, + {filepath.Join(dirs.SnapServicesDir, "snap.foo.timer"), ""}, + {filepath.Join(dirs.SnapServicesDir, "snap.foo.socket"), ""}, + {filepath.Join(dirs.SnapServicesDir, "snap-foo.mount"), ""}, + {filepath.Join(dirs.SnapServicesDir, "multi-user.target.wants", "snap-foo.mount"), ""}, + {filepath.Join(dirs.SnapDataDir, "foo", "bar"), ""}, + {filepath.Join(dirs.SnapCacheDir, "foocache", "bar"), ""}, + {filepath.Join(apparmor_sandbox.CacheDir, "foo", "bar"), ""}, + {filepath.Join(dirs.SnapAppArmorDir, "foo"), ""}, + {filepath.Join(dirs.SnapAssertsDBDir, "foo"), ""}, + {filepath.Join(dirs.FeaturesDir, "foo"), ""}, + {filepath.Join(dirs.SnapDeviceDir, "foo-1", "bar"), ""}, + {filepath.Join(dirs.SnapCookieDir, "foo"), ""}, + {filepath.Join(dirs.SnapSeqDir, "foo.json"), ""}, + {filepath.Join(dirs.SnapMountDir, "foo", "bin"), ""}, + {filepath.Join(dirs.SnapSeccompDir, "foo.bin"), ""}, + {filepath.Join(runinhibit.InhibitDir, "foo.lock"), ""}, + // bash-completion symlinks + {filepath.Join(dirs.CompletersDir, "foo.bar"), "/a/snapd/complete.sh"}, + {filepath.Join(dirs.CompletersDir, "foo"), "foo.bar"}, + } + + for _, art := range artifacts { + fullPath := filepath.Join(tmpDir, art.path) + // create parent dir + c.Assert(os.MkdirAll(filepath.Dir(fullPath), 0755), IsNil) + if art.symlinkTarget != "" { + // note, symlinkTarget is not relative to tmpDir + c.Assert(os.Symlink(art.symlinkTarget, fullPath), IsNil) + } else { + c.Assert(ioutil.WriteFile(fullPath, nil, os.ModePerm), IsNil) + } + } + + checkArtifacts := func(exists bool) { + for _, art := range artifacts { + fullPath := filepath.Join(tmpDir, art.path) + if art.symlinkTarget != "" { + c.Check(osutil.IsSymlink(fullPath), Equals, exists, Commentf("offending symlink: %s", fullPath)) + } else { + c.Check(osutil.FileExists(fullPath), Equals, exists, Commentf("offending file: %s", fullPath)) + } + } + } + + // sanity + checkArtifacts(true) + + snapdDir := filepath.Dir(dirs.SnapStateFile) + c.Assert(os.MkdirAll(filepath.Join(tmpDir, snapdDir), 0755), IsNil) + c.Assert(ioutil.WriteFile(filepath.Join(tmpDir, dirs.SnapStateFile), nil, os.ModePerm), IsNil) + + parser := testParser(c) + c.Assert(main.Run(parser, []string{"--reset", resetDirArg}), IsNil) + + checkArtifacts(false) + + // running reset again is ok + parser = testParser(c) + c.Assert(main.Run(parser, []string{"--reset", resetDirArg}), IsNil) + + // reset complains if target directory doesn't exist + c.Assert(main.Run(parser, []string{"--reset", "/non/existing/chrootpath"}), ErrorMatches, `cannot reset non-existing directory "/non/existing/chrootpath"`) + + // reset complains if target is not a directory + dummyFile := filepath.Join(resetDirArg, "foo") + c.Assert(ioutil.WriteFile(dummyFile, nil, os.ModePerm), IsNil) + err = main.Run(parser, []string{"--reset", dummyFile}) + // the error message is always with an absolute file, so make the path + // absolute if we are running the relative test to properly match + if isRelative { + var err2 error + dummyFile, err2 = filepath.Abs(dummyFile) + c.Assert(err2, IsNil) + } + c.Assert(err, ErrorMatches, fmt.Sprintf(`cannot reset %q, it is not a directory`, dummyFile)) + } + +} + +func (s *startPreseedSuite) TestReadInfoSanity(c *C) { + var called bool + inf := &snap.Info{ + BadInterfaces: make(map[string]string), + Plugs: map[string]*snap.PlugInfo{ + "foo": { + Interface: "bad"}, + }, + } + + // set a dummy 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_linux.go b/cmd/snap-preseed/preseed_linux.go new file mode 100644 index 00000000..aaa60afa --- /dev/null +++ b/cmd/snap-preseed/preseed_linux.go @@ -0,0 +1,283 @@ +// -*- 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 + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "syscall" + + "github.com/snapcore/snapd/dirs" + "github.com/snapcore/snapd/osutil" + "github.com/snapcore/snapd/osutil/squashfs" + "github.com/snapcore/snapd/seed" + "github.com/snapcore/snapd/snapdtool" + "github.com/snapcore/snapd/strutil" + "github.com/snapcore/snapd/timings" +) + +var ( + // snapdMountPath is where target core/snapd is going to be mounted in the target chroot + snapdMountPath = "/tmp/snapd-preseed" + syscallMount = syscall.Mount + syscallChroot = syscall.Chroot +) + +// checkChroot does a basic sanity check of the target chroot environment, e.g. makes +// sure critical virtual filesystems (such as proc) are mounted. This is not meant to +// be exhaustive check, but one that prevents running the tool against a wrong directory +// by an accident, which would lead to hard to understand errors from snapd in preseed +// mode. +func checkChroot(preseedChroot string) error { + exists, isDir, err := osutil.DirExists(preseedChroot) + if err != nil { + return fmt.Errorf("cannot verify %q: %v", preseedChroot, err) + } + if !exists || !isDir { + return fmt.Errorf("cannot verify %q: is not a directory", preseedChroot) + } + + if osutil.FileExists(filepath.Join(preseedChroot, dirs.SnapStateFile)) { + return fmt.Errorf("the system at %q appears to be preseeded, pass --reset flag to clean it up", preseedChroot) + } + + // sanity checks of the critical mountpoints inside chroot directory. + required := map[string]bool{} + // required mountpoints are relative to the preseed chroot + for _, p := range []string{"/sys/kernel/security", "/proc", "/dev"} { + required[filepath.Join(preseedChroot, p)] = true + } + entries, err := osutil.LoadMountInfo() + if err != nil { + return fmt.Errorf("cannot parse mount info: %v", err) + } + for _, ent := range entries { + if _, ok := required[ent.MountDir]; ok { + delete(required, ent.MountDir) + } + } + // non empty required indicates missing mountpoint(s) + if len(required) > 0 { + var sorted []string + for path := range required { + sorted = append(sorted, path) + } + sort.Strings(sorted) + parts := append([]string{""}, sorted...) + return fmt.Errorf("cannot preseed without the following mountpoints:%s", strings.Join(parts, "\n - ")) + } + + path := filepath.Join(preseedChroot, "/sys/kernel/security/apparmor") + if exists := osutil.FileExists(path); !exists { + return fmt.Errorf("cannot preseed without access to %q", path) + } + + return nil +} + +var seedOpen = seed.Open + +var systemSnapFromSeed = func(rootDir string) (string, error) { + seedDir := filepath.Join(dirs.SnapSeedDirUnder(rootDir)) + seed, err := seedOpen(seedDir, "") + if err != nil { + return "", err + } + + // load assertions into temporary database + if err := seed.LoadAssertions(nil, nil); err != nil { + return "", err + } + model := seed.Model() + + tm := timings.New(nil) + if err := seed.LoadMeta(tm); err != nil { + return "", err + } + + // TODO: implement preseeding for core. + if !model.Classic() { + return "", fmt.Errorf("preseeding is only supported on classic systems") + } + + var required string + if seed.UsesSnapdSnap() { + required = "snapd" + } else { + required = "core" + } + + var snapPath string + ess := seed.EssentialSnaps() + if len(ess) > 0 { + // core / snapd snap is the first essential snap. + if ess[0].SnapName() == required { + snapPath = ess[0].Path + } + } + + if snapPath == "" { + return "", fmt.Errorf("%s snap not found", required) + } + + return snapPath, nil +} + +const snapdPreseedSupportVer = `2.43.3+` + +type targetSnapdInfo struct { + path string + version string +} + +// chooseTargetSnapdVersion checks if the version of snapd under chroot env +// is good enough for preseeding. It checks both the snapd from the deb +// and from the seeded snap mounted under snapdMountPath and returns the +// information (path, version) about snapd to execute as part of preseeding +// (it picks the newer version of the two). +// The function must be called after syscall.Chroot(..). +func chooseTargetSnapdVersion() (*targetSnapdInfo, error) { + // read snapd version from the mounted core/snapd snap + infoPath := filepath.Join(snapdMountPath, dirs.CoreLibExecDir, "info") + verFromSnap, err := snapdtool.SnapdVersionFromInfoFile(infoPath) + if err != nil { + return nil, err + } + + // read snapd version from the main fs under chroot (snapd from the deb); + // assumes running under chroot already. + infoPath = filepath.Join(dirs.GlobalRootDir, dirs.CoreLibExecDir, "info") + verFromDeb, err := snapdtool.SnapdVersionFromInfoFile(infoPath) + if err != nil { + return nil, err + } + + res, err := strutil.VersionCompare(verFromSnap, verFromDeb) + if err != nil { + return nil, err + } + + var whichVer, snapdPath string + if res < 0 { + // snapd from the deb under chroot is the candidate to run + whichVer = verFromDeb + snapdPath = filepath.Join(dirs.GlobalRootDir, dirs.CoreLibExecDir, "snapd") + } else { + // snapd from the mounted core/snapd snap is the candidate to run + whichVer = verFromSnap + snapdPath = filepath.Join(snapdMountPath, dirs.CoreLibExecDir, "snapd") + } + + res, err = strutil.VersionCompare(whichVer, snapdPreseedSupportVer) + if err != nil { + return nil, err + } + if res < 0 { + return nil, fmt.Errorf("snapd %s from the target system does not support preseeding, the minimum required version is %s", + whichVer, snapdPreseedSupportVer) + } + + return &targetSnapdInfo{path: snapdPath, version: whichVer}, nil +} + +func prepareChroot(preseedChroot string) (*targetSnapdInfo, func(), error) { + if err := syscallChroot(preseedChroot); err != nil { + return nil, nil, fmt.Errorf("cannot chroot into %s: %v", preseedChroot, err) + } + + if err := os.Chdir("/"); err != nil { + return nil, nil, fmt.Errorf("cannot chdir to /: %v", err) + } + + // GlobalRootDir is now relative to chroot env. We assume all paths + // inside the chroot to be identical with the host. + rootDir := dirs.GlobalRootDir + if rootDir == "" { + rootDir = "/" + } + + coreSnapPath, err := systemSnapFromSeed(rootDir) + if err != nil { + return nil, nil, err + } + + // create mountpoint for core/snapd + where := filepath.Join(rootDir, snapdMountPath) + if err := os.MkdirAll(where, 0755); err != nil { + return nil, nil, err + } + + removeMountpoint := func() { + if err := os.Remove(where); err != nil { + fmt.Fprintf(Stderr, "%v", err) + } + } + + fstype, fsopts := squashfs.FsType() + cmd := exec.Command("mount", "-t", fstype, "-o", strings.Join(fsopts, ","), coreSnapPath, where) + if err := cmd.Run(); err != nil { + removeMountpoint() + return nil, nil, fmt.Errorf("cannot mount %s at %s in preseed mode: %v ", coreSnapPath, where, err) + } + + unmount := func() { + fmt.Fprintf(Stdout, "unmounting: %s\n", snapdMountPath) + cmd := exec.Command("umount", snapdMountPath) + if err := cmd.Run(); err != nil { + fmt.Fprintf(Stderr, "%v", err) + } + } + + targetSnapd, err := chooseTargetSnapdVersion() + if err != nil { + unmount() + removeMountpoint() + return nil, nil, err + } + + return targetSnapd, func() { + unmount() + removeMountpoint() + }, nil +} + +// runPreseedMode runs snapd in a preseed mode. It assumes running in a chroot. +// The chroot is expected to be set-up and ready to use (critical system directories mounted). +func runPreseedMode(preseedChroot string, targetSnapd *targetSnapdInfo) error { + // run snapd in preseed mode + cmd := exec.Command(targetSnapd.path) + cmd.Env = os.Environ() + cmd.Env = append(cmd.Env, "SNAPD_PRESEED=1") + cmd.Stderr = Stderr + cmd.Stdout = Stdout + + // note, snapdPath is relative to preseedChroot + fmt.Fprintf(Stdout, "starting to preseed root: %s\nusing snapd binary: %s (%s)\n", preseedChroot, targetSnapd.path, targetSnapd.version) + + if err := cmd.Run(); err != nil { + return fmt.Errorf("error running snapd in preseed mode: %v\n", err) + } + + return nil +} diff --git a/cmd/snap-preseed/preseed_other.go b/cmd/snap-preseed/preseed_other.go new file mode 100644 index 00000000..72c8c607 --- /dev/null +++ b/cmd/snap-preseed/preseed_other.go @@ -0,0 +1,41 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- +// +build !linux + +/* + * 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" +) + +var preseedNotAvailableError = errors.New("preseed mode not available for systems other than linux") + +func checkChroot(preseedChroot string) error { + return preseedNotAvailableError +} + +func prepareChroot(preseedChroot string) (*targetSnapdInfo, func(), error) { + return nil, preseedNotAvailableError +} + +func runPreseedMode(rootDir string) error { + return preseedNotAvailableError +} + +func cleanup() {} diff --git a/cmd/snap-preseed/reset.go b/cmd/snap-preseed/reset.go new file mode 100644 index 00000000..c3a9d66d --- /dev/null +++ b/cmd/snap-preseed/reset.go @@ -0,0 +1,166 @@ +// -*- 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" + "io/ioutil" + "os" + "path/filepath" + + "github.com/snapcore/snapd/cmd/snaplock/runinhibit" + "github.com/snapcore/snapd/dirs" + "github.com/snapcore/snapd/osutil" + apparmor_sandbox "github.com/snapcore/snapd/sandbox/apparmor" +) + +func resetPreseededChroot(preseedChroot string) error { + exists, isDir, err := osutil.DirExists(preseedChroot) + if err != nil { + return fmt.Errorf("cannot reset %q: %v", preseedChroot, err) + } + if !exists { + return fmt.Errorf("cannot reset non-existing directory %q", preseedChroot) + } + if !isDir { + return fmt.Errorf("cannot reset %q, it is not a directory", preseedChroot) + } + + // globs that yield individual files + globs := []string{ + dirs.SnapStateFile, + dirs.SnapSystemKeyFile, + filepath.Join(dirs.SnapBlobDir, "*.snap"), + filepath.Join(dirs.SnapUdevRulesDir, "*-snap.*.rules"), + filepath.Join(dirs.SnapDBusSystemPolicyDir, "snap.*.*.conf"), + filepath.Join(dirs.SnapServicesDir, "snap.*.service"), + filepath.Join(dirs.SnapServicesDir, "snap.*.timer"), + filepath.Join(dirs.SnapServicesDir, "snap.*.socket"), + filepath.Join(dirs.SnapServicesDir, "snap-*.mount"), + filepath.Join(dirs.SnapServicesDir, "multi-user.target.wants", "snap-*.mount"), + filepath.Join(dirs.SnapUserServicesDir, "snap.*.service"), + filepath.Join(dirs.SnapUserServicesDir, "snap.*.socket"), + filepath.Join(dirs.SnapUserServicesDir, "snap.*.timer"), + filepath.Join(dirs.SnapUserServicesDir, "default.target.wants", "snap.*.service"), + filepath.Join(dirs.SnapUserServicesDir, "sockets.target.wants", "snap.*.socket"), + filepath.Join(dirs.SnapUserServicesDir, "timers.target.wants", "snap.*.timer"), + filepath.Join(runinhibit.InhibitDir, "*.lock"), + } + + for _, gl := range globs { + matches, err := filepath.Glob(filepath.Join(preseedChroot, gl)) + if err != nil { + // the only possible error from Glob() is ErrBadPattern + return err + } + for _, path := range matches { + if err := os.Remove(path); err != nil { + return fmt.Errorf("error removing %s: %v", path, err) + } + } + } + + // directories that need to be removed recursively (but + // leaving parent directory intact). + globs = []string{ + filepath.Join(dirs.SnapDataDir, "*"), + filepath.Join(dirs.SnapCacheDir, "*"), + filepath.Join(apparmor_sandbox.CacheDir, "*"), + filepath.Join(dirs.SnapDesktopFilesDir, "*"), + filepath.Join(dirs.SnapDBusSessionServicesDir, "*"), + filepath.Join(dirs.SnapDBusSystemServicesDir, "*"), + } + + for _, gl := range globs { + matches, err := filepath.Glob(filepath.Join(preseedChroot, gl)) + if err != nil { + // the only possible error from Glob() is ErrBadPattern + return err + } + for _, path := range matches { + if err := os.RemoveAll(path); err != nil { + return fmt.Errorf("error removing %s: %v", path, err) + } + } + } + + // directories removed entirely + paths := []string{ + dirs.SnapAssertsDBDir, + dirs.FeaturesDir, + dirs.SnapDesktopIconsDir, + dirs.SnapDeviceDir, + dirs.SnapCookieDir, + dirs.SnapMountPolicyDir, + dirs.SnapAppArmorDir, + dirs.SnapSeqDir, + dirs.SnapMountDir, + dirs.SnapSeccompBase, + } + + for _, path := range paths { + if err := os.RemoveAll(filepath.Join(preseedChroot, path)); err != nil { + // report the error and carry on + return fmt.Errorf("error removing %s: %v", path, err) + } + } + + // bash-completion symlinks; note there are symlinks that point at + // completer, and symlinks that point at the completer symlinks. + // e.g. + // lxd.lxc -> /snap/core/current/usr/lib/snapd/complete.sh + // lxc -> lxd.lxc + files, err := ioutil.ReadDir(filepath.Join(preseedChroot, dirs.CompletersDir)) + if err != nil && !os.IsNotExist(err) { + return fmt.Errorf("error reading %s: %v", dirs.CompletersDir, err) + } + completeShSymlinks := make(map[string]string) + var otherSymlinks []string + + // pass 1: find all symlinks pointing at complete.sh + for _, fileInfo := range files { + if fileInfo.Mode()&os.ModeSymlink == 0 { + continue + } + fullPath := filepath.Join(preseedChroot, dirs.CompletersDir, fileInfo.Name()) + if dirs.IsCompleteShSymlink(fullPath) { + if err := os.Remove(fullPath); err != nil { + return fmt.Errorf("error removing symlink %s: %v", fullPath, err) + } + completeShSymlinks[fileInfo.Name()] = fullPath + } else { + otherSymlinks = append(otherSymlinks, fullPath) + } + } + // pass 2: find all symlinks that point at the symlinks found in pass 1. + for _, other := range otherSymlinks { + target, err := os.Readlink(other) + if err != nil { + return fmt.Errorf("error reading symlink target of %s: %v", other, err) + } + if _, ok := completeShSymlinks[target]; ok { + if err := os.Remove(other); err != nil { + return fmt.Errorf("error removing symlink %s: %v", other, err) + } + } + } + + return nil +} 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..81a72e02 --- /dev/null +++ b/cmd/snap-recovery-chooser/main.go @@ -0,0 +1,221 @@ +// -*- 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" + "io/ioutil" + "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 := ioutil.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 := ioutil.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() + // sanity + 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 := ioutil.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() + // sanity + 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() + // sanity + 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) TestMainChooserDefaultsToConsoleConf(c *C) { + d := c.MkDir() + dirs.SetRootDir(d) + defer dirs.SetRootDir("/") + + r := main.MockDefaultMarkerFile(s.markerFile) + defer r() + // sanity + 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 := testutil.MockCommand(c, filepath.Join(dirs.GlobalRootDir, "/usr/bin/console-conf"), ` +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) TestMainChooserNoConsoleConf(c *C) { + d := c.MkDir() + dirs.SetRootDir(d) + defer dirs.SetRootDir("/") + + r := main.MockDefaultMarkerFile(s.markerFile) + defer r() + // sanity + 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 tool ".*/usr/bin/console-conf" does 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() + // sanity + 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() + // sanity + 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..ecdf15eb --- /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/ioutil" + "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 := ioutil.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..98a7eb12 --- /dev/null +++ b/cmd/snap-repair/cmd_run.go @@ -0,0 +1,103 @@ +// -*- 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" + "net/url" + "os" + "path/filepath" + + "github.com/snapcore/snapd/dirs" + "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/" + } + + var err error + baseURL, err = url.Parse(baseurl) + if err != nil { + panic(fmt.Sprintf("cannot setup base url: %v", err)) + } +} + +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 { + repair, err := run.Next("canonical") + if err == ErrRepairNotFound { + // no more repairs + break + } + 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..810764a6 --- /dev/null +++ b/cmd/snap-repair/cmd_run_test.go @@ -0,0 +1,94 @@ +// -*- 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/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) 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..8796e218 --- /dev/null +++ b/cmd/snap-repair/cmd_show_test.go @@ -0,0 +1,142 @@ +// -*- 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) + + err := repair.ParseArgs([]string{"show", "canonical-1"}) + 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..03b5e9fa --- /dev/null +++ b/cmd/snap-repair/export_test.go @@ -0,0 +1,147 @@ +// -*- 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 +) + +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) 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 MockErrtrackerReportRepair(mock func(string, string, string, map[string]string) (string, error)) (restore func()) { + prev := errtrackerReportRepair + errtrackerReportRepair = mock + return func() { errtrackerReportRepair = prev } +} + +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..c75c9f73 --- /dev/null +++ b/cmd/snap-repair/runner.go @@ -0,0 +1,1101 @@ +// -*- 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" + "io/ioutil" + "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/errtracker" + "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 +) + +var errtrackerReportRepair = errtracker.ReportRepair + +// 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: add SNAPD_RECOVER_MODE if the repair assertion is for uc20 + + 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 { + scriptErr = fmt.Errorf("repair %s revision %d failed: %s", r, r.Revision(), scriptErr) + if err := r.errtrackerReport(scriptErr, status, logPath); err != nil { + logger.Noticef("cannot report error to errtracker: %s", err) + } + // 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 +} + +// errtrackerReport reports an repairErr with the given logPath to the +// snap error tracker. +func (r *Repair) errtrackerReport(repairErr error, status RepairStatus, logPath string) error { + errMsg := repairErr.Error() + + scriptOutput, err := ioutil.ReadFile(logPath) + if err != nil { + logger.Noticef("cannot read %s", logPath) + } + s := fmt.Sprintf("%s/%d", r.BrandID(), r.RepairID()) + + dupSig := fmt.Sprintf("%s\n%s\noutput:\n%s", s, errMsg, scriptOutput) + extra := map[string]string{ + "Revision": strconv.Itoa(r.Revision()), + "BrandID": r.BrandID(), + "RepairID": strconv.Itoa(r.RepairID()), + "Status": status.String(), + } + _, err = errtrackerReportRepair(s, errMsg, dupSig, extra) + return err +} + +// 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 +) + +// 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) { + 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) { + 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 && !asserts.IsNotFound(err) { + return err + } + if asserts.IsNotFound(err) { + 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 && !asserts.IsNotFound(err) { + return err + } + if err == nil { + bottom = true + } else { + key, err = workBS.Get(asserts.AccountKeyType, signKey, acctKeyMaxSuppFormat) + if err != nil && !asserts.IsNotFound(err) { + return err + } + if asserts.IsNotFound(err) { + 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{}); 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 := ioutil.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 + + return &deviceInfo{ + Brand: modelAs.BrandID(), + Model: modelAs.Model(), + Base: modelAs.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 + } + } + + // TODO:UC20: need to consider filtering by bases and modes in the assertion + // here + + 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..467bbd7d --- /dev/null +++ b/cmd/snap-repair/runner_test.go @@ -0,0 +1,1871 @@ +// -*- 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" + "io/ioutil" + "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 + + restoreLogger func() +} + +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 +} + +const freshStateJSON = `{"device":{"brand":"my-brand","model":"my-model","base":"","mode":""},"time-lower-bound":"2017-08-11T15:49:49Z"}` + +func (s *baseRunnerSuite) freshState(c *C) { + err := os.MkdirAll(dirs.SnapRepairDir, 0775) + c.Assert(err, IsNil) + err = ioutil.WriteFile(dirs.SnapRepairStateFile, []byte(freshStateJSON), 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) + + c.Check(dirs.SnapRepairStateFile, testutil.FileEquals, `{"device":{"brand":"my-brand","model":"my-model","base":"","mode":""},"sequences":{"canonical":[{"sequence":1,"revision":3,"status":0}]},"time-lower-bound":"2017-08-11T15:49:49Z"}`) +} + +func (s *runnerSuite) TestApplicable(c *C) { + s.freshState(c) + runner := repair.NewRunner() + err := runner.LoadState() + c.Assert(err, IsNil) + + scenarios := []struct { + headers map[string]interface{} + applicable bool + }{ + {nil, true}, + {map[string]interface{}{"series": []interface{}{"18"}}, false}, + {map[string]interface{}{"series": []interface{}{"18", "16"}}, true}, + {map[string]interface{}{"series": "18"}, false}, + {map[string]interface{}{"series": []interface{}{18}}, false}, + {map[string]interface{}{"architectures": []interface{}{arch.DpkgArchitecture()}}, true}, + {map[string]interface{}{"architectures": []interface{}{"other-arch"}}, false}, + {map[string]interface{}{"architectures": []interface{}{"other-arch", arch.DpkgArchitecture()}}, true}, + {map[string]interface{}{"architectures": arch.DpkgArchitecture()}, false}, + {map[string]interface{}{"models": []interface{}{"my-brand/my-model"}}, true}, + {map[string]interface{}{"models": []interface{}{"other-brand/other-model"}}, false}, + {map[string]interface{}{"models": []interface{}{"other-brand/other-model", "my-brand/my-model"}}, true}, + {map[string]interface{}{"models": "my-brand/my-model"}, false}, + // model prefix matches + {map[string]interface{}{"models": []interface{}{"my-brand/*"}}, true}, + {map[string]interface{}{"models": []interface{}{"my-brand/my-mod*"}}, true}, + {map[string]interface{}{"models": []interface{}{"my-brand/xxx*"}}, false}, + {map[string]interface{}{"models": []interface{}{"my-brand/my-mod*", "my-brand/xxx*"}}, true}, + {map[string]interface{}{"models": []interface{}{"my*"}}, false}, + {map[string]interface{}{"disabled": "true"}, false}, + {map[string]interface{}{"disabled": "false"}, true}, + } + + for _, scen := range scenarios { + 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(strings.Contains(ua, "snap-repair"), Equals, true) + + 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 := ioutil.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() + + // sanity + c.Check(dirs.SnapRepairStateFile, testutil.FileEquals, freshStateJSON) + + _, err := runner.Next("canonical") + c.Assert(err, Equals, repair.ErrRepairNotFound) + + // we saved new time lower bound + t1 := runner.TimeLowerBound() + expected := strings.Replace(freshStateJSON, "2017-08-11T15:49:49Z", t1.Format(time.RFC3339), 1) + c.Check(expected, Not(Equals), freshStateJSON) + c.Check(dirs.SnapRepairStateFile, testutil.FileEquals, expected) +} + +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 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 + + restoreErrTrackerReportRepair func() + errReport struct { + repair string + errMsg string + dupSig string + extra map[string]string + } +} + +var _ = Suite(&runScriptSuite{}) + +func (s *runScriptSuite) SetUpTest(c *C) { + s.baseRunnerSuite.SetUpTest(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() + + s.runDir = filepath.Join(dirs.SnapRepairRunDir, "canonical", "1") + + restoreErrTrackerReportRepair := repair.MockErrtrackerReportRepair(s.errtrackerReportRepair) + s.AddCleanup(restoreErrTrackerReportRepair) +} + +func (s *runScriptSuite) errtrackerReportRepair(repair, errMsg, dupSig string, extra map[string]string) (string, error) { + s.errReport.repair = repair + s.errReport.errMsg = errMsg + s.errReport.dupSig = dupSig + s.errReport.extra = extra + + return "some-oops-id", nil +} + +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 := ioutil.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]) + } +} + +type byMtime []os.FileInfo + +func (m byMtime) Len() int { return len(m) } +func (m byMtime) Less(i, j int) bool { return m[i].ModTime().Before(m[j].ModTime()) } +func (m byMtime) Swap(i, j int) { m[i], m[j] = m[j], m[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) { + 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) { + 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) + + c.Check(s.errReport.repair, Equals, "canonical/1") + c.Check(s.errReport.errMsg, Equals, `repair canonical-1 revision 0 failed: exit status 1`) + c.Check(s.errReport.dupSig, Equals, `canonical/1 +repair canonical-1 revision 0 failed: exit status 1 +output: +repair: canonical-1 +revision: 0 +summary: repair one +output: +unhappy output +`) + c.Check(s.errReport.extra, DeepEquals, map[string]string{ + "Revision": "0", + "RepairID": "1", + "BrandID": "canonical", + "Status": "retry", + }) +} + +func (s *runScriptSuite) TestRepairBasicSkip(c *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) { + 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) { + 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) { + 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, fmt.Sprintf(`(?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) +} + +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) { + // sanity + 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") + + 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.seedAssertsDir = filepath.Join(dirs.SnapSeedDir, "assertions") + + // dummy seed yaml + err := os.MkdirAll(s.seedAssertsDir, 0755) + c.Assert(err, IsNil) + seedYamlFn := filepath.Join(dirs.SnapSeedDir, "seed.yaml") + err = ioutil.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 := ioutil.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) { + // sanity + 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 := ioutil.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.seedAssertsDir = filepath.Join(dirs.SnapSeedDir, "/systems/20201212/assertions") + err := os.MkdirAll(s.seedAssertsDir, 0755) + c.Assert(err, IsNil) + + // write dummy modeenv + err = os.MkdirAll(filepath.Dir(dirs.SnapModeenvFile), 0755) + c.Assert(err, IsNil) + err = ioutil.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 := ioutil.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 := ioutil.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") +} diff --git a/cmd/snap-repair/staging.go b/cmd/snap-repair/staging.go new file mode 100644 index 00000000..dca15c1a --- /dev/null +++ b/cmd/snap-repair/staging.go @@ -0,0 +1,81 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- +// +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/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..2a55bf1e --- /dev/null +++ b/cmd/snap-repair/trace_test.go @@ -0,0 +1,66 @@ +// -*- 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/ioutil" + "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 = ioutil.WriteFile(filepath.Join(basedir, "r3.retry"), []byte("repair: canonical-1\nsummary: repair one\noutput:\nretry output"), 0600) + c.Assert(err, IsNil) + err = ioutil.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 = ioutil.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 = ioutil.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 = ioutil.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 = ioutil.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 = ioutil.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 = ioutil.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..e1e415da --- /dev/null +++ b/cmd/snap-seccomp-blacklist/snap-seccomp-blacklist.c @@ -0,0 +1,222 @@ +#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). */ + + const 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); + + 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..48d23d84 --- /dev/null +++ b/cmd/snap-seccomp/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 + +var ( + Compile = compile + SeccompResolver = seccompResolver + VersionInfo = versionInfo + GoSeccompFeatures = goSeccompFeatures +) + +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 MockErrnoOnDenial(i int16) (retore func()) { + origErrnoOnDenial := errnoOnDenial + errnoOnDenial = i + return func() { + errnoOnDenial = origErrnoOnDenial + } +} + +func MockSeccompSyscalls(syscalls []string) (resture func()) { + old := seccompSyscalls + seccompSyscalls = syscalls + return func() { + seccompSyscalls = old + } +} diff --git a/cmd/snap-seccomp/main.go b/cmd/snap-seccomp/main.go new file mode 100644 index 00000000..81670ce8 --- /dev/null +++ b/cmd/snap-seccomp/main.go @@ -0,0 +1,870 @@ +// -*- 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 +// //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 +// +// // 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 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 +import "C" + +import ( + "bufio" + "bytes" + "fmt" + "io/ioutil" + "os" + "strconv" + "strings" + "syscall" + + // FIXME: we want github.com/seccomp/libseccomp-golang but that + // will not work with trusty because libseccomp-golang checks + // for the seccomp version and errors if it find one < 2.2.0 + "github.com/mvo5/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, + + // 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 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, +} + +// 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 + } + panic(fmt.Sprintf("cannot map dpkg arch %q to a seccomp arch", 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, +} + +// 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 +} + +func parseLine(line string, secFilter *seccomp.ScmpFilter) error { + // ignore comments and empty lines + if strings.HasPrefix(line, "#") || line == "" { + return nil + } + + // 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) + } + + // fish out syscall + syscallName := tokens[0] + 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, seccomp.ActAllow, conds); err != nil { + err = secFilter.AddRuleConditional(secSyscall, seccomp.ActAllow, conds) + } + + return err +} + +// 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 +} + +var errnoOnDenial int16 = C.EPERM + +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 ActLog. + return seccomp.ActAllow +} + +func compile(content []byte, out string) error { + var err error + var secFilter *seccomp.ScmpFilter + + unrestricted, complain := preprocess(content) + switch { + case unrestricted: + return osutil.AtomicWrite(out, bytes.NewBufferString("@unrestricted\n"), 0644, 0) + case complain: + var complainAct seccomp.ScmpAction = complainAction() + + secFilter, 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 + secFilter, err = seccomp.NewFilter(complainAct) + } + } + + // 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: + secFilter, err = seccomp.NewFilter(seccomp.ActErrno.SetReturnCode(errnoOnDenial)) + } + if err != nil { + return fmt.Errorf("cannot create seccomp filter: %s", err) + } + if err := addSecondaryArches(secFilter); err != nil { + return err + } + + if !unrestricted { + scanner := bufio.NewScanner(bytes.NewBuffer(content)) + for scanner.Scan() { + if err := parseLine(scanner.Text(), secFilter); err != nil { + return fmt.Errorf("cannot parse line: %s", err) + } + } + if scanner.Err(); err != nil { + return err + } + } + + if osutil.GetenvBool("SNAP_SECCOMP_DEBUG") { + secFilter.ExportPFC(os.Stdout) + } + + // write atomically + fout, err := osutil.NewAtomicFile(out, 0644, 0, osutil.NoChown, osutil.NoChown) + if err != nil { + return err + } + // Cancel once Committed is a NOP + defer fout.Cancel() + + if err := secFilter.ExportBPF(fout.File); err != nil { + return err + } + return fout.Commit() +} + +// 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.IsValidUsername(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.IsValidUsername(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 = ioutil.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_ppc64le.go b/cmd/snap-seccomp/main_ppc64le.go new file mode 100644 index 00000000..fdfc2543 --- /dev/null +++ b/cmd/snap-seccomp/main_ppc64le.go @@ -0,0 +1,31 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- +// +// +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_test.go b/cmd/snap-seccomp/main_test.go new file mode 100644 index 00000000..6e5c45e5 --- /dev/null +++ b/cmd/snap-seccomp/main_test.go @@ -0,0 +1,842 @@ +// -*- 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/ioutil" + "math/rand" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" + + . "gopkg.in/check.v1" + + "github.com/mvo5/libseccomp-golang" + + "github.com/snapcore/snapd/arch" + main "github.com/snapcore/snapd/cmd/snap-seccomp" + "github.com/snapcore/snapd/osutil" + "github.com/snapcore/snapd/release" + "github.com/snapcore/snapd/testutil" +) + +// 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 + Allow +) + +var seccompBpfLoaderContent = []byte(` +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#define MAX_BPF_SIZE 32 * 1024 + +int sc_apply_seccomp_bpf(const char* profile_path) +{ + unsigned char bpf[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; + } + + // set 'size' to 1; to get bytes transferred + size_t num_read = fread(bpf, 1, sizeof(bpf), fp); + + if (ferror(fp) != 0) { + perror("fread()"); + return -1; + } else if (feof(fp) == 0) { + fprintf(stderr, "file too big\n"); + return -1; + } + fclose(fp); + + struct sock_fprog prog = { + .len = num_read / sizeof(struct sock_filter), + .filter = (struct sock_filter*)bpf, + }; + + // 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)) { + perror("prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, ...) 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 + if (syscall_ret < 0 && errno == 911) { + ret = 10; + } + syscall(SYS_exit, ret, 0, 0, 0, 0, 0); + return 0; +} +`) + +func (s *snapSeccompSuite) SetUpSuite(c *C) { + main.MockErrnoOnDenial(911) + + // build seccomp-load helper + s.seccompBpfLoader = filepath.Join(c.MkDir(), "seccomp_bpf_loader") + err := ioutil.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 = ioutil.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, seccompWhitelist, 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+seccompWhitelist), 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" + // 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" whitelist, it geneates a whitelist 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, Equals, true, Commentf("unexpected exit code: %v for %v - test setup broken", exitCode, seccompWhitelist)) + switch expected { + case Allow: + if err != nil { + c.Fatalf("unexpected error for %q (failed to run %q)", seccompWhitelist, err) + } + case Deny: + if err == nil { + c.Fatalf("unexpected success for %q %q (ran but should have failed)", seccompWhitelist, 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) + + c.Check(outPath, testutil.FileEquals, inp) +} + +// TestCompile iterates over a range of textual seccomp whitelist 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 { + seccompWhitelist string + bpfInput string + expected int + }{ + // special + {"@complain", "execve", Allow}, + + // trivial allow + {"read", "read", Allow}, + {"read\nwrite\nexecve\n", "write", Allow}, + + // trivial denial + {"read", "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}, + + // 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}, + + // 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.seccompWhitelist, 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 { + seccompWhitelist 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.seccompWhitelist, 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"} { + seccompWhitelist := fmt.Sprintf("socket %s_%s", pre, i) + bpfInputGood := fmt.Sprintf("socket;native;%s_%s", pre, i) + bpfInputBad := "socket;native;99999" + s.runBpf(c, seccompWhitelist, bpfInputGood, Allow) + s.runBpf(c, seccompWhitelist, bpfInputBad, Deny) + + for _, j := range []string{"SOCK_STREAM", "SOCK_DGRAM", "SOCK_SEQPACKET", "SOCK_RAW", "SOCK_RDM", "SOCK_PACKET"} { + seccompWhitelist := 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, seccompWhitelist, bpfInputGood, Allow) + s.runBpf(c, seccompWhitelist, 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"} { + seccompWhitelist := 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, seccompWhitelist, bpfInputGood, Allow) + s.runBpf(c, seccompWhitelist, 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 + seccompWhitelist := fmt.Sprintf("quotactl %s", arg) + bpfInputGood := fmt.Sprintf("quotactl;native;%s", arg) + s.runBpf(c, seccompWhitelist, bpfInputGood, Allow) + // bad input + for _, bad := range []string{"quotactl;native;99999", "read;native;"} { + s.runBpf(c, seccompWhitelist, 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 + seccompWhitelist := fmt.Sprintf("prctl %s", arg) + bpfInputGood := fmt.Sprintf("prctl;native;%s", arg) + s.runBpf(c, seccompWhitelist, bpfInputGood, Allow) + // bad input + for _, bad := range []string{"prctl;native;99999", "setpriority;native;"} { + s.runBpf(c, seccompWhitelist, 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"} { + seccompWhitelist := fmt.Sprintf("prctl %s %s", arg, j) + bpfInputGood := fmt.Sprintf("prctl;native;%s,%s", arg, j) + s.runBpf(c, seccompWhitelist, bpfInputGood, Allow) + for _, bad := range []string{ + fmt.Sprintf("prctl;native;%s,99999", arg), + "setpriority;native;", + } { + s.runBpf(c, seccompWhitelist, bad, Deny) + } + } + } + } +} + +// ported from test_restrictions_working_args_clone +func (s *snapSeccompSuite) TestRestrictionsWorkingArgsClone(c *C) { + for _, t := range []struct { + seccompWhitelist 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.seccompWhitelist, t.bpfInput, t.expected) + } +} + +// ported from test_restrictions_working_args_mknod +func (s *snapSeccompSuite) TestRestrictionsWorkingArgsMknod(c *C) { + for _, t := range []struct { + seccompWhitelist 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.seccompWhitelist, t.bpfInput, t.expected) + } +} + +// ported from test_restrictions_working_args_prio +func (s *snapSeccompSuite) TestRestrictionsWorkingArgsPrio(c *C) { + for _, t := range []struct { + seccompWhitelist 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.seccompWhitelist, t.bpfInput, t.expected) + } +} + +// ported from test_restrictions_working_args_termios +func (s *snapSeccompSuite) TestRestrictionsWorkingArgsTermios(c *C) { + for _, t := range []struct { + seccompWhitelist 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.seccompWhitelist, 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 { + seccompWhitelist 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.seccompWhitelist, 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 + seccompWhitelist 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.seccompWhitelist, t.bpfInput, t.expected) + } + } +} diff --git a/cmd/snap-seccomp/syscalls/syscalls.go b/cmd/snap-seccomp/syscalls/syscalls.go new file mode 100644 index 00000000..b6e34a58 --- /dev/null +++ b/cmd/snap-seccomp/syscalls/syscalls.go @@ -0,0 +1,494 @@ +// -*- 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 bf747eb21e428c2b3ead6ebcca27951b681963a0. +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", + "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", + "connect", + "copy_file_range", + "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", + "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", + "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", + "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", + "lchown", + "lchown32", + "lgetxattr", + "link", + "linkat", + "listen", + "listxattr", + "llistxattr", + "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_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_vm_readv", + "process_vm_writev", + "prof", + "profil", + "pselect6", + "pselect6_time64", + "ptrace", + "putpmsg", + "pwrite64", + "pwritev", + "pwritev2", + "query_module", + "quotactl", + "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_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..b206d9b8 --- /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/mvo5/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..24e01141 --- /dev/null +++ b/cmd/snap-seccomp/versioninfo_test.go @@ -0,0 +1,75 @@ +// -*- 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" + + . "gopkg.in/check.v1" + + "github.com/mvo5/libseccomp-golang" + + 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..bb844aaa --- /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 oprimise 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. +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. +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..fdfc2543 --- /dev/null +++ b/cmd/snap-update-ns/bootstrap_ppc64le.go @@ -0,0 +1,31 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- +// +// +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..37d72999 --- /dev/null +++ b/cmd/snap-update-ns/change.go @@ -0,0 +1,696 @@ +// -*- 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/features" + "github.com/snapcore/snapd/logger" + "github.com/snapcore/snapd/osutil" + "github.com/snapcore/snapd/osutil/mount" +) + +// 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 ( + // 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.(type) { + case *ReadOnlyFsError: + rofsErr := err.(*ReadOnlyFsError) + return true, rofsErr.Path + case *TrespassingError: + tErr := err.(*TrespassingError) + 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 + + // In case we need to create something, some constants. + const ( + uid = 0 + gid = 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) + } + 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 (original error: %v)", path, err) + changes, err = createWritableMimic(mimicPath, path, 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) + } + } + } else if os.IsNotExist(err) { + changes, err = c.createPath(path, true, 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 + + // 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 + } + + kind := c.Entry.XSnapdKind() + 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 + 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": + // 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) + } + + // 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) + 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 + } + 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 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 + } + if features.RobustMountNamespaceUpdates.IsEnabled() { + // FIXME: This should not be necessary. It is necessary because + // mimic construction code is not considering all layouts in tandem + // and doesn't know enough about base file system to construct + // mimics in the order that would prevent them from nesting. + // + // By ignoring EBUSY here and by continuing to tear down the mimic + // tmpfs entirely (without any reuse) we guarantee that at the end + // of the day the nested mimic case is entirely removed. + // + // In an ideal world we would model this better and could do + // without this edge case. + if (kind == "" || kind == "file") && err == syscall.EBUSY { + logger.Debugf("cannot remove busy mount point %q", path) + return nil + } + } + } + return err + case Keep: + as.AddChange(c) + return nil + } + return fmt.Errorf("cannot process mount change: unknown action: %q", c.Action) +} + +// neededChangesOld is the real implementation of NeededChanges +// This function is used when RobustMountNamespaceUpdate is not enabled. +func neededChangesOld(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) + } + + // Sort both lists by directory name with implicit trailing slash. + sort.Sort(byOriginAndMagicDir(current)) + sort.Sort(byOriginAndMagicDir(desired)) + + // 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[string]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. + 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 + + // 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[dir] = 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[dir] = 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. + for i := len(current) - 1; i >= 0; i-- { + if reuse[current[i].Dir] { + changes = append(changes, &Change{Action: Keep, Entry: current[i]}) + } else { + var entry osutil.MountEntry = current[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}) + } + } + + // Mount desired entries not reused. + for i := range desired { + if !reuse[desired[i].Dir] { + changes = append(changes, &Change{Action: Mount, Entry: desired[i]}) + } + } + + return changes +} + +// neededChangesNew is the real implementation of NeededChanges +// This function is used when RobustMountNamespaceUpdate is enabled. +func neededChangesNew(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) + } + + // Sort both lists by directory name with implicit trailing slash. + sort.Sort(byOriginAndMagicDir(current)) + sort.Sort(byOriginAndMagicDir(desired)) + + // We are now ready to compute the necessary mount changes. + var changes []*Change + + // Unmount entries in reverse order, so that the most nested element is + // always processed first. + for i := len(current) - 1; i >= 0; i-- { + var entry osutil.MountEntry = current[i] + entry.Options = append([]string(nil), entry.Options...) + switch { + case entry.XSnapdSynthetic() && entry.Type == "tmpfs": + // Synthetic changes are rooted under a tmpfs, detach that tmpfs to + // remove them all. + if !entry.XSnapdDetach() { + entry.Options = append(entry.Options, osutil.XSnapdDetach()) + } + case entry.XSnapdSynthetic(): + // Consume all other syn ethic entries without emitting either a + // mount, unmount or keep change. This relies on the fact that all + // synthetic mounts are created by a mimic underneath a tmpfs that + // is detached, as coded above. + continue + case entry.OptBool("rbind") || entry.Type == "tmpfs": + // Recursive bind mounts and non-mimic tmpfs mounts need to be + // detached because they can contain other mount points that can + // otherwise propagate in a self-conflicting way. + if !entry.XSnapdDetach() { + entry.Options = append(entry.Options, osutil.XSnapdDetach()) + } + case entry.OptBool("bind") && entry.XSnapdKind() == "file": + // Bind mounted files are detached. If a bind mounted file open or + // mapped into a process as a library, then attempting to unmount + // it will result in EBUSY. + // + // This can happen when a snap has a service, for example one using + // a library mounted via a bind mount and an absent content + // connection. Subsequent connection of the content connection will + // trigger re-population of the mount namespace, which will start + // by tearing down the existing file bind-mount. To prevent this, + // detach the mount instead. + if !entry.XSnapdDetach() { + entry.Options = append(entry.Options, osutil.XSnapdDetach()) + } + } + // Unmount all changes that were not eliminated. + changes = append(changes, &Change{Action: Unmount, Entry: entry}) + } + + // Mount desired entries. + for i := range desired { + changes = append(changes, &Change{Action: Mount, Entry: desired[i]}) + } + + return changes +} + +// NeededChanges computes the changes required to change current to desired mount entries. +// +// The algorithm differs depending on the value of the robust mount namespace +// updates feature flag. If the flag is enabled then the current profile is +// entirely undone and the desired profile is constructed from scratch. +// +// If the flag is disabled then a diff-like operation on the mount profile is +// computed. Some of the mount entries from the current profile may be reused. +// The diff approach doesn't function correctly in cases of nested mimics. +var NeededChanges = func(current, desired *osutil.MountProfile) []*Change { + if features.RobustMountNamespaceUpdates.IsEnabled() { + return neededChangesNew(current, desired) + } + return neededChangesOld(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..4fbb41ba --- /dev/null +++ b/cmd/snap-update-ns/change_test.go @@ -0,0 +1,3030 @@ +// -*- 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/ioutil" + "os" + "strings" + "syscall" + + . "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/osutil" + "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) disableRobustMountNamespaceUpdates(c *C) { + if dirs.GlobalRootDir == "/" { + dirs.SetRootDir(c.MkDir()) + s.BaseTest.AddCleanup(func() { dirs.SetRootDir("/") }) + } + c.Assert(os.MkdirAll(dirs.FeaturesDir, 0755), IsNil) + err := os.Remove(features.RobustMountNamespaceUpdates.ControlFile()) + if err != nil && !os.IsNotExist(err) { + c.Assert(err, IsNil) + } +} + +func (s *changeSuite) enableRobustMountNamespaceUpdates(c *C) { + if dirs.GlobalRootDir == "/" { + dirs.SetRootDir(c.MkDir()) + s.BaseTest.AddCleanup(func() { dirs.SetRootDir("/") }) + } + c.Assert(os.MkdirAll(dirs.FeaturesDir, 0755), IsNil) + err := ioutil.WriteFile(features.RobustMountNamespaceUpdates.ControlFile(), []byte(nil), 0644) + c.Assert(err, IsNil) +} + +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) TestNeededChangesNoProfilesOld(c *C) { + s.disableRobustMountNamespaceUpdates(c) + + current := &osutil.MountProfile{} + desired := &osutil.MountProfile{} + changes := update.NeededChanges(current, desired) + c.Assert(changes, IsNil) +} + +// When there are no profiles we don't do anything. +func (s *changeSuite) TestNeededChangesNoProfilesNew(c *C) { + s.enableRobustMountNamespaceUpdates(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) TestNeededChangesNoChangeOld(c *C) { + s.disableRobustMountNamespaceUpdates(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}, + }) +} + +func (s *changeSuite) TestNeededChangesNoChangeNew(c *C) { + s.enableRobustMountNamespaceUpdates(c) + + current := &osutil.MountProfile{ + Entries: []osutil.MountEntry{ + {Dir: "/common/stuff"}, + {Dir: "/common/file", Options: []string{"bind", "x-snapd.kind=file"}}, + }, + } + 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.Unmount}, + // File bind mounts are detached. + {Entry: osutil.MountEntry{Dir: "/common/file", Options: []string{"bind", "x-snapd.kind=file", "x-snapd.detach"}}, Action: update.Unmount}, + {Entry: osutil.MountEntry{Dir: "/common/stuff"}, Action: update.Mount}, + }) +} + +// When the content interface is connected we should mount the new entry. +func (s *changeSuite) TestNeededChangesTrivialMountOld(c *C) { + s.disableRobustMountNamespaceUpdates(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}, + }) +} + +func (s *changeSuite) TestNeededChangesTrivialMountNew(c *C) { + s.enableRobustMountNamespaceUpdates(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) TestNeededChangesTrivialUnmountOld(c *C) { + s.disableRobustMountNamespaceUpdates(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}, + }) +} + +func (s *changeSuite) TestNeededChangesTrivialUnmountNew(c *C) { + s.enableRobustMountNamespaceUpdates(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 umounting we unmount children before parents. +func (s *changeSuite) TestNeededChangesUnmountOrderOld(c *C) { + s.disableRobustMountNamespaceUpdates(c) + + current := &osutil.MountProfile{Entries: []osutil.MountEntry{ + {Dir: "/common/stuff/extra"}, + {Dir: "/common/stuff"}, + }} + 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}, + }) +} + +func (s *changeSuite) TestNeededChangesUnmountOrderNew(c *C) { + s.enableRobustMountNamespaceUpdates(c) + + current := &osutil.MountProfile{Entries: []osutil.MountEntry{ + {Dir: "/common/stuff/extra"}, + {Dir: "/common/stuff"}, + }} + 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) TestNeededChangesMountOrderOld(c *C) { + s.disableRobustMountNamespaceUpdates(c) + + current := &osutil.MountProfile{} + desired := &osutil.MountProfile{Entries: []osutil.MountEntry{ + {Dir: "/common/stuff/extra"}, + {Dir: "/common/stuff"}, + }} + changes := update.NeededChanges(current, desired) + c.Assert(changes, DeepEquals, []*update.Change{ + {Entry: osutil.MountEntry{Dir: "/common/stuff"}, Action: update.Mount}, + {Entry: osutil.MountEntry{Dir: "/common/stuff/extra"}, Action: update.Mount}, + }) +} + +func (s *changeSuite) TestNeededChangesMountOrderNew(c *C) { + s.enableRobustMountNamespaceUpdates(c) + + current := &osutil.MountProfile{} + desired := &osutil.MountProfile{Entries: []osutil.MountEntry{ + {Dir: "/common/stuff/extra"}, + {Dir: "/common/stuff"}, + }} + changes := update.NeededChanges(current, desired) + c.Assert(changes, DeepEquals, []*update.Change{ + {Entry: osutil.MountEntry{Dir: "/common/stuff"}, Action: update.Mount}, + {Entry: osutil.MountEntry{Dir: "/common/stuff/extra"}, Action: update.Mount}, + }) +} + +// When parent changes we don't reuse its children + +func (s *changeSuite) TestNeededChangesChangedParentSameChildOld(c *C) { + s.disableRobustMountNamespaceUpdates(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}, + }) +} + +func (s *changeSuite) TestNeededChangesChangedParentSameChildNew(c *C) { + s.enableRobustMountNamespaceUpdates(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.Unmount}, + {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}, + {Entry: osutil.MountEntry{Dir: "/common/unrelated"}, Action: update.Mount}, + }) +} + +// When child changes we don't touch the unchanged parent +func (s *changeSuite) TestNeededChangesSameParentChangedChildOld(c *C) { + s.disableRobustMountNamespaceUpdates(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}, + }) +} + +func (s *changeSuite) TestNeededChangesSameParentChangedChildNew(c *C) { + s.enableRobustMountNamespaceUpdates(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.Unmount}, + {Entry: osutil.MountEntry{Dir: "/common/stuff/extra", Name: "/dev/sda1"}, Action: update.Unmount}, + {Entry: osutil.MountEntry{Dir: "/common/stuff"}, Action: update.Unmount}, + {Entry: osutil.MountEntry{Dir: "/common/stuff"}, Action: update.Mount}, + {Entry: osutil.MountEntry{Dir: "/common/stuff/extra", Name: "/dev/sda2"}, Action: update.Mount}, + {Entry: osutil.MountEntry{Dir: "/common/unrelated"}, Action: update.Mount}, + }) +} + +// Unused bind mount farms are unmounted. +func (s *changeSuite) TestNeededChangesTmpfsBindMountFarmUnusedOld(c *C) { + s.disableRobustMountNamespaceUpdates(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: "/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: "/snap/other/123/libs", + Dir: "/snap/name/42/subdir/created", + Options: []string{"bind", "ro", "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) TestNeededChangesTmpfsBindMountFarmUnusedNew(c *C) { + s.enableRobustMountNamespaceUpdates(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"}, + }, 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) TestNeededChangesTmpfsBindMountFarmUsedOld(c *C) { + s.disableRobustMountNamespaceUpdates(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: "/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: "/snap/other/123/libs", + Dir: "/snap/name/42/subdir/created", + Options: []string{"bind", "ro"}, + }, 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}, + }) +} + +func (s *changeSuite) TestNeededChangesTmpfsBindMountFarmUsedNew(c *C) { + s.enableRobustMountNamespaceUpdates(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.Unmount}, + {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", "x-snapd.detach"}, + }, Action: update.Unmount}, + {Entry: osutil.MountEntry{ + Name: "/snap/other/123/libs", + Dir: "/snap/name/42/subdir/created", + Options: []string{"bind", "ro"}, + }, Action: update.Mount}, + }) +} + +// 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) { + s.disableRobustMountNamespaceUpdates(c) + + current := &osutil.MountProfile{Entries: []osutil.MountEntry{ + {Dir: "/a/b", Name: "/dev/sda1"}, + {Dir: "/a/b-1"}, + {Dir: "/a/b-1/3"}, + {Dir: "/a/b/c"}, + }} + 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) + c.Assert(changes, DeepEquals, []*update.Change{ + {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-1/3"}, Action: update.Unmount}, + {Entry: osutil.MountEntry{Dir: "/a/b-1"}, Action: update.Keep}, + + {Entry: osutil.MountEntry{Dir: "/a/b", Name: "/dev/sda2"}, Action: update.Mount}, + {Entry: osutil.MountEntry{Dir: "/a/b/c"}, Action: update.Mount}, + }) +} + +func (s *changeSuite) TestNeededChangesSmartEntryComparisonNew(c *C) { + s.enableRobustMountNamespaceUpdates(c) + + current := &osutil.MountProfile{Entries: []osutil.MountEntry{ + {Dir: "/a/b", Name: "/dev/sda1"}, + {Dir: "/a/b-1"}, + {Dir: "/a/b-1/3"}, + {Dir: "/a/b/c"}, + }} + 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) + c.Assert(changes, DeepEquals, []*update.Change{ + {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-1/3"}, Action: update.Unmount}, + {Entry: osutil.MountEntry{Dir: "/a/b-1"}, Action: update.Unmount}, + + {Entry: osutil.MountEntry{Dir: "/a/b-1"}, Action: update.Mount}, + {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) TestNeededChangesParallelInstancesManyComeFirstOld(c *C) { + s.disableRobustMountNamespaceUpdates(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}, + }) +} + +func (s *changeSuite) TestNeededChangesParallelInstancesManyComeFirstNew(c *C) { + s.enableRobustMountNamespaceUpdates(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) TestNeededChangesParallelInstancesKeepOld(c *C) { + s.disableRobustMountNamespaceUpdates(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: "/snap/foo", Name: "/snap/foo_bar", Options: []string{osutil.XSnapdOriginOvername()}}, + {Dir: "/foo/bar", Name: "/foo/bar_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}, + }) +} + +func (s *changeSuite) TestNeededChangesParallelInstancesKeepNew(c *C) { + s.enableRobustMountNamespaceUpdates(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: "/snap/foo", Name: "/snap/foo_bar", Options: []string{osutil.XSnapdOriginOvername()}}, + {Dir: "/foo/bar", Name: "/foo/bar_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.Unmount}, + {Entry: osutil.MountEntry{Dir: "/foo/bar", Name: "/foo/bar_bar", Options: []string{osutil.XSnapdOriginOvername()}}, Action: update.Unmount}, + {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/unrelated"}, Action: update.Mount}, + }) +} + +// Parallel instance with mounts inside +func (s *changeSuite) TestNeededChangesParallelInstancesInsideMountOld(c *C) { + s.disableRobustMountNamespaceUpdates(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/zed"}, + {Dir: "/snap/foo", Name: "/snap/foo_bar", Options: []string{osutil.XSnapdOriginOvername()}}, + {Dir: "/foo/bar", Name: "/foo/bar_bar", Options: []string{osutil.XSnapdOriginOvername()}}, + }} + changes := update.NeededChanges(current, desired) + c.Assert(changes, DeepEquals, []*update.Change{ + {Entry: osutil.MountEntry{Dir: "/foo/bar/zed"}, Action: update.Unmount}, + {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: "/foo/bar/baz"}, Action: update.Mount}, + }) +} + +func (s *changeSuite) TestNeededChangesParallelInstancesInsideMountNew(c *C) { + s.enableRobustMountNamespaceUpdates(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/zed"}, + {Dir: "/snap/foo", Name: "/snap/foo_bar", Options: []string{osutil.XSnapdOriginOvername()}}, + {Dir: "/foo/bar", Name: "/foo/bar_bar", Options: []string{osutil.XSnapdOriginOvername()}}, + }} + changes := update.NeededChanges(current, desired) + c.Assert(changes, DeepEquals, []*update.Change{ + {Entry: osutil.MountEntry{Dir: "/foo/bar/zed"}, Action: update.Unmount}, + {Entry: osutil.MountEntry{Dir: "/snap/foo", Name: "/snap/foo_bar", Options: []string{osutil.XSnapdOriginOvername()}}, Action: update.Unmount}, + {Entry: osutil.MountEntry{Dir: "/foo/bar", Name: "/foo/bar_bar", Options: []string{osutil.XSnapdOriginOvername()}}, Action: update.Unmount}, + {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: "/foo/bar/baz"}, Action: update.Mount}, + }) +} + +func mustReadProfile(profileStr string) *osutil.MountProfile { + profile, err := osutil.ReadMountProfile(strings.NewReader(profileStr)) + if err != nil { + panic(err) + } + return profile +} + +func (s *changeSuite) TestRuntimeUsingSymlinksOld(c *C) { + s.disableRobustMountNamespaceUpdates(c) + + // 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: "/opt/runtime", Type: "none", Options: []string{"x-snapd.kind=symlink", "x-snapd.symlink=/snap/app/x1/runtime", "x-snapd.origin=layout"}}, + {Name: "/snap/runtime/x1/opt/runtime", Dir: "/snap/app/x1/runtime", 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: "none", Dir: "/opt/runtime", Type: "none", Options: []string{"x-snapd.kind=symlink", "x-snapd.symlink=/snap/app/x1/runtime", "x-snapd.origin=layout"}}, Action: update.Mount}, + {Entry: osutil.MountEntry{Name: "/snap/runtime/x1/opt/runtime", Dir: "/snap/app/x1/runtime", Type: "none", Options: []string{"bind", "ro"}}, 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: "/snap/runtime/x1/opt/runtime", Dir: "/snap/app/x1/runtime", Type: "none", Options: []string{"bind", "ro"}}, + {Name: "none", Dir: "/opt/runtime", Type: "none", Options: []string{"x-snapd.kind=symlink", "x-snapd.symlink=/snap/app/x1/runtime", "x-snapd.origin=layout"}}, + {Name: "tmpfs", Dir: "/opt", Type: "tmpfs", Options: []string{"x-snapd.synthetic", "x-snapd.needed-by=/opt/runtime", "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: "/snap/app/x2/runtime", Dir: "/opt/runtime", Type: "none", Options: []string{"rbind", "rw", "x-snapd.origin=layout"}}, + {Name: "/snap/runtime/x1/opt/runtime", Dir: "/snap/app/x2/runtime", 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 dropping the content interface bind mount because app changed revision + {Entry: osutil.MountEntry{Name: "/snap/runtime/x1/opt/runtime", Dir: "/snap/app/x1/runtime", Type: "none", Options: []string{"bind", "ro", "x-snapd.detach"}}, Action: update.Unmount}, + // We are not keeping /opt, it's safer this way. + {Entry: osutil.MountEntry{Name: "none", Dir: "/opt/runtime", Type: "none", Options: []string{"x-snapd.kind=symlink", "x-snapd.symlink=/snap/app/x1/runtime", "x-snapd.origin=layout"}}, Action: update.Unmount}, + // We are re-creating /opt from scratch. + {Entry: osutil.MountEntry{Name: "tmpfs", Dir: "/opt", Type: "tmpfs", Options: []string{"x-snapd.synthetic", "x-snapd.needed-by=/opt/runtime", "mode=0755", "uid=0", "gid=0"}}, Action: update.Keep}, + // We are adding a new bind mount for /opt/runtime + {Entry: osutil.MountEntry{Name: "/snap/app/x2/runtime", Dir: "/opt/runtime", Type: "none", Options: []string{"rbind", "rw", "x-snapd.origin=layout"}}, Action: update.Mount}, + // We also adding the updated path of the content interface (for revision x2) + {Entry: osutil.MountEntry{Name: "/snap/runtime/x1/opt/runtime", Dir: "/snap/app/x2/runtime", Type: "none", Options: []string{"bind", "ro"}}, Action: update.Mount}, + }) + + // After performing all those changes this is the profile we observe. + currentV2 := &osutil.MountProfile{Entries: []osutil.MountEntry{ + {Name: "tmpfs", Dir: "/opt", Type: "tmpfs", Options: []string{"x-snapd.synthetic", "x-snapd.needed-by=/opt/runtime", "mode=0755", "uid=0", "gid=0", "x-snapd.detach"}}, + {Name: "/snap/app/x2/runtime", Dir: "/opt/runtime", Type: "none", Options: []string{"rbind", "rw", "x-snapd.origin=layout"}}, + {Name: "/snap/runtime/x1/opt/runtime", Dir: "/snap/app/x2/runtime", 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, again, dropping the content interface bind mount because app changed revision + {Entry: osutil.MountEntry{Name: "/snap/runtime/x1/opt/runtime", Dir: "/snap/app/x2/runtime", 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: "/snap/app/x2/runtime", Dir: "/opt/runtime", Type: "none", Options: []string{"rbind", "rw", "x-snapd.origin=layout", "x-snapd.detach"}}, Action: update.Unmount}, + // Again, recreate the tmpfs. + {Entry: osutil.MountEntry{Name: "tmpfs", Dir: "/opt", Type: "tmpfs", Options: []string{"x-snapd.synthetic", "x-snapd.needed-by=/opt/runtime", "mode=0755", "uid=0", "gid=0", "x-snapd.detach"}}, Action: update.Keep}, + // We are providing a symlink /opt/runtime -> to $SNAP/runtime. + {Entry: osutil.MountEntry{Name: "none", Dir: "/opt/runtime", Type: "none", Options: []string{"x-snapd.kind=symlink", "x-snapd.symlink=/snap/app/x1/runtime", "x-snapd.origin=layout"}}, Action: update.Mount}, + // We are bind mounting the runtime from another snap into $SNAP/runtime + {Entry: osutil.MountEntry{Name: "/snap/runtime/x1/opt/runtime", Dir: "/snap/app/x1/runtime", Type: "none", Options: []string{"bind", "ro"}}, 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. +} + +func (s *changeSuite) TestRuntimeUsingSymlinksNew(c *C) { + s.enableRobustMountNamespaceUpdates(c) + + // 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: "/opt/runtime", Type: "none", Options: []string{"x-snapd.kind=symlink", "x-snapd.symlink=/snap/app/x1/runtime", "x-snapd.origin=layout"}}, + {Name: "/snap/runtime/x1/opt/runtime", Dir: "/snap/app/x1/runtime", 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: "none", Dir: "/opt/runtime", Type: "none", Options: []string{"x-snapd.kind=symlink", "x-snapd.symlink=/snap/app/x1/runtime", "x-snapd.origin=layout"}}, Action: update.Mount}, + {Entry: osutil.MountEntry{Name: "/snap/runtime/x1/opt/runtime", Dir: "/snap/app/x1/runtime", Type: "none", Options: []string{"bind", "ro"}}, 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: "/snap/runtime/x1/opt/runtime", Dir: "/snap/app/x1/runtime", Type: "none", Options: []string{"bind", "ro"}}, + {Name: "none", Dir: "/opt/runtime", Type: "none", Options: []string{"x-snapd.kind=symlink", "x-snapd.symlink=/snap/app/x1/runtime", "x-snapd.origin=layout"}}, + {Name: "tmpfs", Dir: "/opt", Type: "tmpfs", Options: []string{"x-snapd.synthetic", "x-snapd.needed-by=/opt/runtime", "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: "/snap/app/x2/runtime", Dir: "/opt/runtime", Type: "none", Options: []string{"rbind", "rw", "x-snapd.origin=layout"}}, + {Name: "/snap/runtime/x1/opt/runtime", Dir: "/snap/app/x2/runtime", 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 dropping the content interface bind mount because app changed revision + {Entry: osutil.MountEntry{Name: "/snap/runtime/x1/opt/runtime", Dir: "/snap/app/x1/runtime", Type: "none", Options: []string{"bind", "ro"}}, Action: update.Unmount}, + // We are not keeping /opt, it's safer this way. + {Entry: osutil.MountEntry{Name: "none", Dir: "/opt/runtime", Type: "none", Options: []string{"x-snapd.kind=symlink", "x-snapd.symlink=/snap/app/x1/runtime", "x-snapd.origin=layout"}}, Action: update.Unmount}, + // We are re-creating /opt from scratch. + {Entry: osutil.MountEntry{Name: "tmpfs", Dir: "/opt", Type: "tmpfs", Options: []string{"x-snapd.synthetic", "x-snapd.needed-by=/opt/runtime", "mode=0755", "uid=0", "gid=0", "x-snapd.detach"}}, Action: update.Unmount}, + // We are adding a new bind mount for /opt/runtime + {Entry: osutil.MountEntry{Name: "/snap/app/x2/runtime", Dir: "/opt/runtime", Type: "none", Options: []string{"rbind", "rw", "x-snapd.origin=layout"}}, Action: update.Mount}, + // We also adding the updated path of the content interface (for revision x2) + {Entry: osutil.MountEntry{Name: "/snap/runtime/x1/opt/runtime", Dir: "/snap/app/x2/runtime", Type: "none", Options: []string{"bind", "ro"}}, Action: update.Mount}, + }) + + // After performing all those changes this is the profile we observe. + currentV2 := &osutil.MountProfile{Entries: []osutil.MountEntry{ + {Name: "tmpfs", Dir: "/opt", Type: "tmpfs", Options: []string{"x-snapd.synthetic", "x-snapd.needed-by=/opt/runtime", "mode=0755", "uid=0", "gid=0", "x-snapd.detach"}}, + {Name: "/snap/app/x2/runtime", Dir: "/opt/runtime", Type: "none", Options: []string{"rbind", "rw", "x-snapd.origin=layout"}}, + {Name: "/snap/runtime/x1/opt/runtime", Dir: "/snap/app/x2/runtime", 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, again, dropping the content interface bind mount because app changed revision + {Entry: osutil.MountEntry{Name: "/snap/runtime/x1/opt/runtime", Dir: "/snap/app/x2/runtime", Type: "none", Options: []string{"bind", "ro"}}, Action: update.Unmount}, + // We are also dropping the bind mount from /opt/runtime since we want a symlink instead + {Entry: osutil.MountEntry{Name: "/snap/app/x2/runtime", Dir: "/opt/runtime", Type: "none", Options: []string{"rbind", "rw", "x-snapd.origin=layout", "x-snapd.detach"}}, Action: update.Unmount}, + // Again, recreate the tmpfs. + {Entry: osutil.MountEntry{Name: "tmpfs", Dir: "/opt", Type: "tmpfs", Options: []string{"x-snapd.synthetic", "x-snapd.needed-by=/opt/runtime", "mode=0755", "uid=0", "gid=0", "x-snapd.detach"}}, Action: update.Unmount}, + // We are providing a symlink /opt/runtime -> to $SNAP/runtime. + {Entry: osutil.MountEntry{Name: "none", Dir: "/opt/runtime", Type: "none", Options: []string{"x-snapd.kind=symlink", "x-snapd.symlink=/snap/app/x1/runtime", "x-snapd.origin=layout"}}, Action: update.Mount}, + // We are bind mounting the runtime from another snap into $SNAP/runtime + {Entry: osutil.MountEntry{Name: "/snap/runtime/x1/opt/runtime", Dir: "/snap/app/x1/runtime", Type: "none", Options: []string{"bind", "ro"}}, 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: []os.FileInfo(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: []os.FileInfo(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: []os.FileInfo(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=/rofs/source", "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: []os.FileInfo(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: []os.FileInfo(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) { + 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, ErrorMatches, "device or resource busy") + 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: []os.FileInfo(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.FakeFileInfo("other.conf", 0755) + s.sys.InsertReadDirResult(`readdir "/etc"`, []os.FileInfo{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) + s.sys.InsertOsLstatResult(`lstat "/etc/other.conf"`, otherConf) + 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: []os.FileInfo{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: otherConf}, + {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: 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}, + }) +} 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..00e54c64 --- /dev/null +++ b/cmd/snap-update-ns/common_test.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_test + +import ( + "bytes" + "io/ioutil" + "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(ioutil.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(ioutil.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/export_test.go b/cmd/snap-update-ns/export_test.go new file mode 100644 index 00000000..7c7a254d --- /dev/null +++ b/cmd/snap-update-ns/export_test.go @@ -0,0 +1,217 @@ +// -*- 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 ( + "os" + "syscall" + + "github.com/snapcore/snapd/osutil" + "github.com/snapcore/snapd/osutil/sys" +) + +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 + DesiredUserProfilePath = desiredUserProfilePath + CurrentUserProfilePath = currentUserProfilePath + + // xdg + XdgRuntimeDir = xdgRuntimeDir + ExpandPrefixVariable = expandPrefixVariable + ExpandXdgRuntimeDir = expandXdgRuntimeDir + + // 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) ([]os.FileInfo, 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 + oldIoutilReadDir := ioutilReadDir + + 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 + ioutilReadDir = 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 + ioutilReadDir = oldIoutilReadDir + + 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 MockChangePerform(f func(chg *Change, as *Assumptions) ([]*Change, error)) func() { + origChangePerform := changePerform + changePerform = f + return func() { + changePerform = origChangePerform + } +} + +func MockNeededChanges(f func(old, new *osutil.MountProfile) []*Change) (restore func()) { + origNeededChanges := NeededChanges + NeededChanges = f + return func() { + NeededChanges = origNeededChanges + } +} + +func MockReadDir(fn func(string) ([]os.FileInfo, error)) (restore func()) { + old := ioutilReadDir + ioutilReadDir = fn + return func() { + ioutilReadDir = old + } +} + +func MockReadlink(fn func(string) (string, error)) (restore func()) { + old := osReadlink + osReadlink = fn + return func() { + osReadlink = 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..7ff04185 --- /dev/null +++ b/cmd/snap-update-ns/main.go @@ -0,0 +1,88 @@ +// -*- 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/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 + } + var upCtx MountProfileUpdateContext + if opts.UserMounts { + upCtx = NewUserProfileUpdateContext(opts.Positionals.SnapName, opts.FromSnapConfine, os.Getuid()) + } 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..a6016f57 --- /dev/null +++ b/cmd/snap-update-ns/main_test.go @@ -0,0 +1,382 @@ +// -*- 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" + "io/ioutil" + "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/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.BaseTest.AddCleanup(restore) + s.log = buf +} + +func (s *mainSuite) TestExecuteMountProfileUpdate(c *C) { + dirs.SetRootDir(c.MkDir()) + defer dirs.SetRootDir("/") + + 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 = ioutil.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 = ioutil.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(ioutil.WriteFile(currentProfilePath, []byte(currentProfileContent), 0644), IsNil) + c.Assert(ioutil.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(ioutil.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(ioutil.WriteFile(currentProfilePath, []byte(currentProfileContent), 0644), IsNil) + c.Assert(ioutil.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"}, + }, + }) + case 1: + c.Assert(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(ioutil.WriteFile(currentProfilePath, []byte(currentProfileContent), 0644), IsNil) + c.Assert(ioutil.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(ioutil.WriteFile(currentProfilePath, []byte(currentProfileContent), 0644), IsNil) + c.Assert(ioutil.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(ioutil.WriteFile(currentProfilePath, []byte(currentProfileContent), 0644), IsNil) + c.Assert(ioutil.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) TestApplyUserFstab(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` + + desiredProfilePath := fmt.Sprintf("%s/snap.%s.user-fstab", dirs.SnapMountPolicyDir, snapName) + err := os.MkdirAll(filepath.Dir(desiredProfilePath), 0755) + c.Assert(err, IsNil) + err = ioutil.WriteFile(desiredProfilePath, []byte(desiredProfileContent), 0644) + c.Assert(err, IsNil) + + upCtx := update.NewUserProfileUpdateContext(snapName, true, 1000) + err = update.ExecuteMountProfileUpdate(upCtx) + c.Assert(err, IsNil) + + xdgRuntimeDir := fmt.Sprintf("%s/%d", dirs.XdgRuntimeDirBase, 1000) + + c.Assert(changes, HasLen, 1) + c.Assert(changes[0].Action, Equals, update.Mount) + c.Assert(changes[0].Entry.Name, Equals, xdgRuntimeDir+"/doc/by-app/snap.foo") + c.Assert(changes[0].Entry.Dir, Matches, xdgRuntimeDir+"/doc") +} 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..cdccf136 --- /dev/null +++ b/cmd/snap-update-ns/sorting.go @@ -0,0 +1,63 @@ +// -*- 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 ( + "strings" + + "github.com/snapcore/snapd/osutil" +) + +// byOriginAndMagicDir allows sorting an array of entries by the source of mount +// entry (overname, layout, content) and lexically by mount point name. +// Automagically adds a trailing slash to paths. +type byOriginAndMagicDir []osutil.MountEntry + +func (c byOriginAndMagicDir) Len() int { return len(c) } +func (c byOriginAndMagicDir) Swap(i, j int) { c[i], c[j] = c[j], c[i] } +func (c byOriginAndMagicDir) 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 +} diff --git a/cmd/snap-update-ns/sorting_test.go b/cmd/snap-update-ns/sorting_test.go new file mode 100644 index 00000000..c3a1e683 --- /dev/null +++ b/cmd/snap-update-ns/sorting_test.go @@ -0,0 +1,129 @@ +// -*- 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 ( + "sort" + + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/osutil" +) + +type sortSuite struct{} + +var _ = Suite(&sortSuite{}) + +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"}, + } + sort.Sort(byOriginAndMagicDir(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"}, + } + sort.Sort(byOriginAndMagicDir(entries)) + // Entries sorted as if they had a trailing slash. + c.Assert(entries, DeepEquals, []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"}, + {Dir: "/a/b", Options: []string{osutil.XSnapdOriginLayout()}}, + {Dir: "/a/b/c"}, + {Dir: "/snap/bar/baz", Options: []string{osutil.XSnapdOriginLayout()}}, + }) +} + +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"}, + } + sort.Sort(byOriginAndMagicDir(entries)) + c.Assert(entries, DeepEquals, expected) + sort.Sort(byOriginAndMagicDir(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()}}, + } + sort.Sort(byOriginAndMagicDir(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..73b1a323 --- /dev/null +++ b/cmd/snap-update-ns/system.go @@ -0,0 +1,99 @@ +// -*- 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/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) + 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.*", 0700) + as.AddModeHint("/var/lib/snapd/hostfs/tmp/snap.*/tmp", 1777) + // 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.*/tmp/.X11-unix", 1777) + 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..bb8b93ac --- /dev/null +++ b/cmd/snap-update-ns/system_test.go @@ -0,0 +1,139 @@ +// -*- 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" + "io/ioutil" + "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 systemSuite struct{} + +var _ = Suite(&systemSuite{}) + +func (s *systemSuite) TestLock(c *C) { + dirs.SetRootDir(c.MkDir()) + defer dirs.SetRootDir("/") + + upCtx := update.NewSystemProfileUpdateContext("foo", false) + unlock, err := upCtx.Lock() + c.Assert(err, IsNil) + c.Check(unlock, NotNil) + unlock() +} + +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", "/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.x11-server"), Equals, os.FileMode(0700)) + c.Check(as.ModeForPath("/var/lib/snapd/hostfs/tmp/snap.x11-server/tmp"), Equals, os.FileMode(1777)) + c.Check(as.ModeForPath("/var/lib/snapd/hostfs/tmp/snap.x11-server/foo"), Equals, os.FileMode(0755)) + c.Check(as.ModeForPath("/var/lib/snapd/hostfs/tmp/snap.x11-server/tmp/.X11-unix"), Equals, os.FileMode(1777)) + + // 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", "/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(ioutil.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(ioutil.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..c7b9b4fa --- /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..aac2abe3 --- /dev/null +++ b/cmd/snap-update-ns/trespassing_test.go @@ -0,0 +1,449 @@ +// -*- 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) 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..d10a3c08 --- /dev/null +++ b/cmd/snap-update-ns/update_test.go @@ -0,0 +1,351 @@ +// -*- 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" + + . "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/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) +} + +// 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..623956b0 --- /dev/null +++ b/cmd/snap-update-ns/user.go @@ -0,0 +1,113 @@ +// -*- 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" + + "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 +} + +// NewUserProfileUpdateContext returns encapsulated information for performing a per-user mount namespace update. +func NewUserProfileUpdateContext(instanceName string, fromSnapConfine bool, uid int) *UserProfileUpdateContext { + return &UserProfileUpdateContext{ + CommonProfileUpdateContext: CommonProfileUpdateContext{ + instanceName: instanceName, + fromSnapConfine: fromSnapConfine, + currentProfilePath: currentUserProfilePath(instanceName, uid), + desiredProfilePath: desiredUserProfilePath(instanceName), + }, + uid: uid, + } +} + +// UID returns the user ID of the mount namespace being updated. +func (upCtx *UserProfileUpdateContext) UID() int { + return upCtx.uid +} + +// 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{} + // 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 + } + // 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..0404e18f --- /dev/null +++ b/cmd/snap-update-ns/user_test.go @@ -0,0 +1,143 @@ +// -*- 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" + "io/ioutil" + "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) TestLock(c *C) { + dirs.SetRootDir(c.MkDir()) + defer dirs.SetRootDir("/") + c.Assert(os.MkdirAll(dirs.FeaturesDir, 0755), IsNil) + + upCtx := update.NewUserProfileUpdateContext("foo", false, 1234) + + // Locking is a no-op. + unlock, err := upCtx.Lock() + c.Assert(err, IsNil) + c.Check(unlock, NotNil) + unlock() +} + +func (s *userSuite) TestAssumptions(c *C) { + upCtx := update.NewUserProfileUpdateContext("foo", false, 1234) + as := upCtx.Assumptions() + c.Check(as.UnrestrictedPaths(), IsNil) +} + +func (s *userSuite) TestLoadDesiredProfile(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" + + upCtx := update.NewUserProfileUpdateContext("foo", false, 1234) + + 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(ioutil.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) TestLoadCurrentProfile(c *C) { + // Mock directories. + dirs.SetRootDir(c.MkDir()) + defer dirs.SetRootDir("/") + + upCtx := update.NewUserProfileUpdateContext("foo", false, 1234) + + // 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(), upCtx.UID()) + c.Assert(os.MkdirAll(filepath.Dir(path), 0755), IsNil) + c.Assert(ioutil.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) + + upCtx := update.NewUserProfileUpdateContext("foo", false, 1234) + + // 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(ioutil.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..3bb3c03a --- /dev/null +++ b/cmd/snap-update-ns/utils.go @@ -0,0 +1,655 @@ +// -*- 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/ioutil" + "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 + + ioutilReadDir = ioutil.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.CurrentCleanName(), 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 +} + +// 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.CurrentBase(), iter.CurrentCleanName(), 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, uint32(perm.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, uint32(perm.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 +} + +// 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 := ioutilReadDir(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.Mode() + 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..6a5d942f --- /dev/null +++ b/cmd/snap-update-ns/utils_test.go @@ -0,0 +1,1178 @@ +// -*- 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" + "io/ioutil" + "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 + +// 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 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) ([]os.FileInfo, error) { + c.Assert(dir, Equals, "/foo") + return []os.FileInfo{ + testutil.FakeFileInfo("file", 0), + testutil.FakeFileInfo("dir", os.ModeDir), + testutil.FakeFileInfo("symlink", os.ModeSymlink), + testutil.FakeFileInfo("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.FakeFileInfo("block-dev", os.ModeDevice), + testutil.FakeFileInfo("char-dev", os.ModeDevice|os.ModeCharDevice), + testutil.FakeFileInfo("socket", os.ModeSocket), + testutil.FakeFileInfo("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) ([]os.FileInfo, 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") +} + +// 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 sanity 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(ioutil.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-update-ns/xdg.go b/cmd/snap-update-ns/xdg.go new file mode 100644 index 00000000..6f3ae35b --- /dev/null +++ b/cmd/snap-update-ns/xdg.go @@ -0,0 +1,56 @@ +// -*- 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" + "strings" + + "github.com/snapcore/snapd/dirs" + "github.com/snapcore/snapd/osutil" +) + +// 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) +} + +// expandPrefixVariable expands variable at the beginning of a path-like string. +func expandPrefixVariable(path, variable, value string) string { + if strings.HasPrefix(path, variable) { + if len(path) == len(variable) { + return value + } + if len(path) > len(variable) && path[len(variable)] == '/' { + return value + path[len(variable):] + } + } + return path +} + +// expandXdgRuntimeDir expands the $XDG_RUNTIME_DIR variable in the given mount profile. +func expandXdgRuntimeDir(profile *osutil.MountProfile, uid int) { + variable := "$XDG_RUNTIME_DIR" + value := xdgRuntimeDir(uid) + for i := range profile.Entries { + profile.Entries[i].Name = expandPrefixVariable(profile.Entries[i].Name, variable, value) + profile.Entries[i].Dir = expandPrefixVariable(profile.Entries[i].Dir, variable, value) + } +} diff --git a/cmd/snap-update-ns/xdg_test.go b/cmd/snap-update-ns/xdg_test.go new file mode 100644 index 00000000..d7e3f1d1 --- /dev/null +++ b/cmd/snap-update-ns/xdg_test.go @@ -0,0 +1,56 @@ +// -*- 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" + "strings" + + . "gopkg.in/check.v1" + + update "github.com/snapcore/snapd/cmd/snap-update-ns" + "github.com/snapcore/snapd/osutil" +) + +type xdgSuite struct{} + +var _ = Suite(&xdgSuite{}) + +func (s *xdgSuite) TestXdgRuntimeDir(c *C) { + c.Check(update.XdgRuntimeDir(1234), Equals, "/run/user/1234") +} + +func (s *xdgSuite) TestExpandPrefixVariable(c *C) { + c.Check(update.ExpandPrefixVariable("$FOO", "$FOO", "/foo"), Equals, "/foo") + c.Check(update.ExpandPrefixVariable("$FOO/", "$FOO", "/foo"), Equals, "/foo/") + c.Check(update.ExpandPrefixVariable("$FOO/bar", "$FOO", "/foo"), Equals, "/foo/bar") + c.Check(update.ExpandPrefixVariable("$FOObar", "$FOO", "/foo"), Equals, "$FOObar") +} + +func (s *xdgSuite) 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) +} 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..92f40ddf --- /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" + "io/ioutil" + + "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 := ioutil.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..b330cab8 --- /dev/null +++ b/cmd/snap/cmd_advise_test.go @@ -0,0 +1,261 @@ +// -*- 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" + "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() + 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..6df45b1a --- /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..d0cd10aa --- /dev/null +++ b/cmd/snap/cmd_aliases.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" + "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. + +An alias noted as undefined means it was explicitly enabled or disabled but is +not defined in the current revision of the snap, possibly temporarily (e.g. +because of a revert). This can cleared with 'snap alias --reset'. +`) + +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..4563c503 --- /dev/null +++ b/cmd/snap/cmd_aliases_test.go @@ -0,0 +1,179 @@ +// -*- 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/ioutil" + "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. + +An alias noted as undefined means it was explicitly enabled or disabled but is +not defined in the current revision of the snap, possibly temporarily (e.g. +because of a revert). This can cleared with 'snap alias --reset'. +` + 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 := ioutil.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 := ioutil.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 := ioutil.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 := ioutil.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..73e4995b --- /dev/null +++ b/cmd/snap/cmd_auto_import.go @@ -0,0 +1,396 @@ +// -*- 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 main + +import ( + "bufio" + "crypto" + "encoding/base64" + "fmt" + "io/ioutil" + "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" + +var mountInfoPath = "/proc/self/mountinfo" + +func autoImportCandidates() ([]string, error) { + var cands []string + + // see https://www.kernel.org/doc/Documentation/filesystems/proc.txt, + // sec. 3.5 + f, err := os.Open(mountInfoPath) + if err != nil { + return nil, err + } + defer f.Close() + + isTesting := snapdenv.Testing() + + // TODO: re-write this to use osutil.LoadMountInfo instead of doing the + // parsing ourselves + + scanner := bufio.NewScanner(f) + for scanner.Scan() { + l := strings.Fields(scanner.Text()) + + // Per proc.txt:3.5, /proc//mountinfo looks like + // + // 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) + // + // and (7) has zero or more elements, find the "-" separator. + i := 6 + for i < len(l) && l[i] != "-" { + i++ + } + if i+2 >= len(l) { + continue + } + + mountSrc := l[i+2] + + // skip everything that is not a device (cgroups, debugfs etc) + if !strings.HasPrefix(mountSrc, "/dev/") { + continue + } + // skip all loop devices (snaps) + if strings.HasPrefix(mountSrc, "/dev/loop") { + continue + } + // skip all ram disks (unless in tests) + if !isTesting && strings.HasPrefix(mountSrc, "/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 := l[4] + 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.HasSuffix(mountPoint, dirs.SnapSeedDir) { + continue + } + + cand := filepath.Join(mountPoint, autoImportsName) + if osutil.FileExists(cand) { + cands = append(cands, cand) + } + } + + return cands, scanner.Err() +} + +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 := ioutil.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 ioutilTempDir = ioutil.TempDir + +func tryMount(deviceName string) (string, error) { + tmpMountTarget, err := ioutilTempDir("", "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 := ioutil.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 := ioutil.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 mode +func inInstallMode() bool { + mode, _, err := boot.ModeAndRecoverySystemFromKernelCommandLine() + if err != nil { + return false + } + return mode == "install" +} + +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-mode\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..355c6d67 --- /dev/null +++ b/cmd/snap/cmd_auto_import_test.go @@ -0,0 +1,562 @@ +// -*- 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/ioutil" + "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 makeMockMountInfo(c *C, content string) string { + fn := filepath.Join(c.MkDir(), "mountinfo") + err := ioutil.WriteFile(fn, []byte(content), 0644) + c.Assert(err, IsNil) + return fn +} + +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 := ioutil.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 := ioutil.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(c.MkDir(), "auto-import.assert") + err := ioutil.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 = snap.MockMountInfoPath(makeMockMountInfo(c, content)) + 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") + }) + + fakeAssertsFn := filepath.Join(c.MkDir(), "auto-import.assert") + err := ioutil.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 = snap.MockMountInfoPath(makeMockMountInfo(c, 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) TestAutoImportCandidatesHappy(c *C) { + dirs := make([]string, 4) + args := make([]interface{}, len(dirs)) + files := make([]string, len(dirs)) + for i := range dirs { + dirs[i] = c.MkDir() + args[i] = dirs[i] + files[i] = filepath.Join(dirs[i], "auto-import.assert") + err := ioutil.WriteFile(files[i], nil, 0644) + c.Assert(err, IsNil) + } + + mockMountInfoFmtWithLoop := ` +too short +24 0 8:18 / %[1]s rw,relatime foo ext3 /dev/meep2 no,separator +24 0 8:18 / %[2]s rw,relatime - ext3 /dev/meep2 rw,errors=remount-ro,data=ordered +24 0 8:18 / %[3]s rw,relatime opt:1 - ext4 /dev/meep3 rw,errors=remount-ro,data=ordered +24 0 8:18 / %[4]s rw,relatime opt:1 opt:2 - ext2 /dev/meep1 rw,errors=remount-ro,data=ordered +` + + content := fmt.Sprintf(mockMountInfoFmtWithLoop, args...) + restore := snap.MockMountInfoPath(makeMockMountInfo(c, content)) + defer restore() + + l, err := snap.AutoImportCandidates() + c.Check(err, IsNil) + c.Check(l, DeepEquals, files[1:]) +} + +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 := ioutil.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 = snap.MockMountInfoPath(makeMockMountInfo(c, 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, "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 := ioutil.WriteFile(fakeAssertsFn, fakeAssertData, 0644) + c.Assert(err, IsNil) + + mockMountInfoFmt := ` +24 0 8:18 / %s rw,relatime shared:1 - squashfs /dev/sc1 rw,errors=remount-ro,data=ordered` + content := fmt.Sprintf(mockMountInfoFmt, filepath.Dir(fakeAssertsFn)) + restore = snap.MockMountInfoPath(makeMockMountInfo(c, 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, "") + // 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 := ioutil.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() + + 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 := ioutil.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 := ioutil.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 = ioutil.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 := ioutil.WriteFile(fakeAssertsFn, fakeAssertData, 0644) + c.Assert(err, IsNil) + + mockMountInfoFmt := ` +24 0 8:18 / %s rw,relatime shared:1 - squashfs /dev/sc1 rw,errors=remount-ro,data=ordered` + content := fmt.Sprintf(mockMountInfoFmt, filepath.Dir(fakeAssertsFn)) + restore = snap.MockMountInfoPath(makeMockMountInfo(c, content)) + 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) { + restore := release.MockOnClassic(false) + defer restore() + + _, restoreLogger := logger.MockLogger() + defer restoreLogger() + + mockProcCmdlinePath := filepath.Join(c.MkDir(), "cmdline") + err := ioutil.WriteFile(mockProcCmdlinePath, []byte("foo=bar snapd_recovery_mode=install snapd_recovery_system=20191118"), 0644) + c.Assert(err, IsNil) + + restore = osutil.MockProcCmdline(mockProcCmdlinePath) + defer restore() + + _, 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-mode\n") +} + +var mountStatic = []string{"mount", "-t", "ext4,vfat", "-o", "ro", "--make-private"} + +func (s *SnapSuite) TestAutoImportFromRemovable(c *C) { + restore := release.MockOnClassic(false) + 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() + + 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() + + _, 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(ioutil.WriteFile(file, nil, 0644), IsNil) + } + + mockMountInfoFmtWithLoop := ` +24 0 8:18 / %[1]s%[2]s rw,relatime foo ext3 /dev/meep2 no,separator +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 := snap.MockMountInfoPath(makeMockMountInfo(c, 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 := ioutil.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 := ioutil.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 := ioutil.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 = snap.MockMountInfoPath(makeMockMountInfo(c, 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..2e617381 --- /dev/null +++ b/cmd/snap/cmd_booted.go @@ -0,0 +1,50 @@ +// -*- 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 dummy 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..df6cf0b3 --- /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/snapcore/snapd/client" + "github.com/snapcore/snapd/i18n" + + "github.com/jessevdk/go-flags" +) + +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..caf4f10e --- /dev/null +++ b/cmd/snap/cmd_changes.go @@ -0,0 +1,209 @@ +// -*- 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/snapcore/snapd/client" + "github.com/snapcore/snapd/i18n" + + "github.com/jessevdk/go-flags" +) + +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 { + return fmt.Errorf(i18n.G("no changes found")) + } + + 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..35aa9bd8 --- /dev/null +++ b/cmd/snap/cmd_changes_test.go @@ -0,0 +1,233 @@ +// -*- 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, "") +} diff --git a/cmd/snap/cmd_confinement.go b/cmd/snap/cmd_confinement.go new file mode 100644 index 00000000..a5b1473f --- /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 ( + "github.com/snapcore/snapd/i18n" + + "fmt" + + "github.com/jessevdk/go-flags" +) + +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..909992f4 --- /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/snapcore/snapd/i18n" + + "github.com/jessevdk/go-flags" +) + +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..5c02f2ef --- /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..48c10882 --- /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/ioutil" + "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 := ioutil.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 := ioutil.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 := ioutil.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 := ioutil.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 := ioutil.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 := ioutil.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 := ioutil.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 := ioutil.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 := ioutil.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 := ioutil.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..3c62f604 --- /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/ioutil" + "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 := ioutil.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 := ioutil.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..fda20d98 --- /dev/null +++ b/cmd/snap/cmd_create_key.go @@ -0,0 +1,92 @@ +// -*- 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" + + "github.com/jessevdk/go-flags" + "golang.org/x/crypto/ssh/terminal" + + "github.com/snapcore/snapd/asserts" + "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) + } + + 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("passphrases do not match") + } + if err != nil { + return err + } + + manager := asserts.NewGPGKeypairManager() + return manager.Generate(string(passphrase), 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..98ddcf44 --- /dev/null +++ b/cmd/snap/cmd_debug_bootvars.go @@ -0,0 +1,57 @@ +// -*- 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" + + "github.com/jessevdk/go-flags" + + "github.com/snapcore/snapd/boot" + "github.com/snapcore/snapd/i18n" + "github.com/snapcore/snapd/release" +) + +type cmdBootvars struct { + UC20 bool `long:"uc20"` + RootDir string `long:"root-dir"` +} + +func init() { + cmd := addDebugCommand("boot-vars", + "(internal) obtain the snapd boot variables", + "(internal) obtain the snapd boot variables", + func() flags.Commander { + return &cmdBootvars{} + }, 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) + if release.OnClassic { + cmd.hidden = true + } +} + +func (x *cmdBootvars) Execute(args []string) error { + if release.OnClassic { + return errors.New(`the "boot-vars" command is not available on classic systems`) + } + return boot.DumpBootVars(Stdout, x.RootDir, x.UC20) +} diff --git a/cmd/snap/cmd_debug_bootvars_test.go b/cmd/snap/cmd_debug_bootvars_test.go new file mode 100644 index 00000000..d0db9600 --- /dev/null +++ b/cmd/snap/cmd_debug_bootvars_test.go @@ -0,0 +1,62 @@ +// -*- 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/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) + bloader.BootVars = 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", + } + + rest, err := snap.Parser(snap.Client()).ParseArgs([]string{"debug", "boot-vars"}) + c.Assert(err, check.IsNil) + c.Assert(rest, check.DeepEquals, []string{}) + 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`) +} 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..1a414a31 --- /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/ioutil" + "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 := ioutil.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..d2658e96 --- /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/ioutil" + "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 := ioutil.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_state.go b/cmd/snap/cmd_debug_state.go new file mode 100644 index 00000000..e95e4694 --- /dev/null +++ b/cmd/snap/cmd_debug_state.go @@ -0,0 +1,335 @@ +// -*- 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" + "sort" + "strconv" + "strings" + "text/tabwriter" + + "github.com/jessevdk/go-flags" + "github.com/snapcore/snapd/i18n" + "github.com/snapcore/snapd/overlord/state" +) + +type cmdDebugState struct { + timeMixin + + st *state.State + + Changes bool `long:"changes"` + TaskID string `long:"task"` + ChangeID string `long:"change"` + + 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 byChangeID []*state.Change + +func (c byChangeID) Len() int { return len(c) } +func (c byChangeID) Swap(i, j int) { c[i], c[j] = c[j], c[i] } +func (c byChangeID) Less(i, j int) bool { return c[i].ID() < c[j].ID() } + +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"), + "is-seeded": i18n.G("Output seeding status (true or false)"), + }), 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 { + // cover the typical case (just one lane), and order by first lane + if t[i].Lanes()[0] == t[j].Lanes()[0] { + return waitChainSearch(t[i], t[j]) + } + return t[i].Lanes()[0] < t[j].Lanes()[0] +} + +func waitChainSearch(startT, searchT *state.Task) bool { + for _, cand := range startT.HaltTasks() { + if cand == searchT { + return true + } + if waitChainSearch(cand, searchT) { + 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 + } + var lanes []string + for _, lane := range t.Lanes() { + lanes = append(lanes, fmt.Sprintf("%d", lane)) + } + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", + strings.Join(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) showChanges(st *state.State) error { + st.Lock() + defer st.Unlock() + + changes := st.Changes() + sort.Sort(byChangeID(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 && err != state.ErrNoState { + return err + } + fmt.Fprintf(Stdout, "%v\n", isSeeded) + + 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 := wrapLine(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 != false { + cmds = append(cmds, "--is-seeded") + } + 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.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) + } + 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) + } + + // 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..df8b30fb --- /dev/null +++ b/cmd/snap/cmd_debug_state_test.go @@ -0,0 +1,232 @@ +// -*- 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 ( + "io/ioutil" + "path/filepath" + + . "gopkg.in/check.v1" + + main "github.com/snapcore/snapd/cmd/snap" +) + +var stateJSON = []byte(` +{ + "last-task-id": 31, + "last-change-id": 2, + + "data": { + "snaps": {}, + "seeded": true + }, + "changes": { + "1": { + "id": "1", + "kind": "install-snap", + "summary": "install a snap", + "status": 0, + "data": {"snap-names": ["a"]}, + "task-ids": ["11","12"] + }, + "2": { + "id": "2", + "kind": "revert-snap", + "summary": "revert c snap", + "status": 0, + "data": {"snap-names": ["c"]}, + "task-ids": ["21","31"] + } + }, + "tasks": { + "11": { + "id": "11", + "change": "1", + "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": "1", "kind": "some-other-task"}, + "21": { + "id": "21", + "change": "2", + "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": "2", + "kind": "prepare-snap", + "summary": "Prepare snap c", + "status": 4, + "data": {"snap-setup": { + "channel": "stable", + "flags": 1073741828 + }}, + "halt-tasks": ["12"], + "log": ["logline1", "logline2"] + } + } +} +`) + +func (s *SnapSuite) TestDebugChanges(c *C) { + dir := c.MkDir() + stateFile := filepath.Join(dir, "test-state.json") + c.Assert(ioutil.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"+ + "1 Do 0001-01-01T00:00:00Z 0001-01-01T00:00:00Z install-snap install a snap\n"+ + "2 Done 0001-01-01T00:00:00Z 0001-01-01T00:00:00Z 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(ioutil.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(ioutil.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(ioutil.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(ioutil.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(ioutil.WriteFile(stateFile, stateJSON, 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"+ + "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) 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(ioutil.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(ioutil.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, "") +} diff --git a/cmd/snap/cmd_debug_timings.go b/cmd/snap/cmd_debug_timings.go new file mode 100644 index 00000000..5fb00c6d --- /dev/null +++ b/cmd/snap/cmd_debug_timings.go @@ -0,0 +1,294 @@ +// -*- 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"` + 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 { + if doing { + 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..ef94cc65 --- /dev/null +++ b/cmd/snap/cmd_debug_timings_test.go @@ -0,0 +1,337 @@ +// -*- 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", +}} + +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 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..f980415b --- /dev/null +++ b/cmd/snap/cmd_debug_validate_seed.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 + +import ( + "github.com/jessevdk/go-flags" + + "github.com/snapcore/snapd/seed" +) + +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 + } + + 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..33f09ad9 --- /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 ( + "io/ioutil" + "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 := ioutil.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..e8dfb71e --- /dev/null +++ b/cmd/snap/cmd_delete_key.go @@ -0,0 +1,61 @@ +// -*- 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/asserts" + "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 + } + + manager := asserts.NewGPGKeypairManager() + return manager.Delete(string(x.Positional.KeyName)) +} diff --git a/cmd/snap/cmd_delete_key_test.go b/cmd/snap/cmd_delete_key_test.go new file mode 100644 index 00000000..1f7dfdcd --- /dev/null +++ b/cmd/snap/cmd_delete_key_test.go @@ -0,0 +1,64 @@ +// -*- 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" + + . "gopkg.in/check.v1" + + snap "github.com/snapcore/snapd/cmd/snap" +) + +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 find key named \"nonexistent\" 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, "") +} diff --git a/cmd/snap/cmd_disconnect.go b/cmd/snap/cmd_disconnect.go new file mode 100644 index 00000000..0144a603 --- /dev/null +++ b/cmd/snap/cmd_disconnect.go @@ -0,0 +1,108 @@ +// -*- 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/snapcore/snapd/client" + "github.com/snapcore/snapd/i18n" + + "github.com/jessevdk/go-flags" +) + +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.SnapAndName + use := x.Positionals.Use.SnapAndName + + // snap disconnect : + // snap disconnect + if use.Snap == "" && use.Name == "" { + // Swap Offer and Use around + offer, use = use, offer + } + if use.Name == "" { + return fmt.Errorf("please provide the plug or slot name to disconnect from snap %q", use.Snap) + } + + 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..dae7170a --- /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, `please provide the plug or slot name to disconnect from snap "consumer"`) + 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..9b1024a0 --- /dev/null +++ b/cmd/snap/cmd_download.go @@ -0,0 +1,184 @@ +// -*- 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" + "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" +) + +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, to which you must have developer access"), + // 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 *image.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, 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 image.DownloadOptions) error { + tsto, err := image.NewToolingStore() + if err != nil { + return err + } + + fmt.Fprintf(Stdout, i18n.G("Fetching snap %q\n"), snapName) + snapPath, snapInfo, _, 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, snapPath, snapInfo) + if err != nil { + return err + } + printInstallHint(assertPath, snapPath) + return nil +} + +func (x *cmdDownload) downloadFromStore(snapName string, revision snap.Revision) error { + dlOpts := image.DownloadOptions{ + 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..c8d00c71 --- /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/image" + "github.com/snapcore/snapd/snap" +) + +// 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 image.DownloadOptions) 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 image.DownloadOptions) 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..bf1580ff --- /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/ioutil" + "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 := ioutil.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..94aa7029 --- /dev/null +++ b/cmd/snap/cmd_export_key.go @@ -0,0 +1,100 @@ +// -*- 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" + "time" + + "github.com/jessevdk/go-flags" + + "github.com/snapcore/snapd/asserts" + "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" + } + + manager := asserts.NewGPGKeypairManager() + if x.Account != "" { + privKey, err := manager.GetByName(keyName) + if err != nil { + return 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 := manager.Export(keyName) + if err != nil { + return 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..ab6ff72d --- /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 find key named \"nonexistent\" 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..61526dec --- /dev/null +++ b/cmd/snap/cmd_find.go @@ -0,0 +1,274 @@ +// -*- 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 + if strings.TrimSpace(x.Positional.Query) == "" { + x.Positional.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 := (x.Positional.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: x.Positional.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..8e6de081 --- /dev/null +++ b/cmd/snap/cmd_find_test.go @@ -0,0 +1,594 @@ +// -*- 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/ioutil" + "net/http" + "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() + if q.Get("q") == "" { + v, ok := q["section"] + c.Check(ok, check.Equals, true) + c.Check(v, check.DeepEquals, []string{""}) + } + fmt.Fprintln(w, findJSON) + default: + c.Fatalf("expected to get 2 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-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 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.Fprintln(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.Fprintln(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.Fprintln(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.Fprintln(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.Fprintln(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.Fprintln(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) + ioutil.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..7de52ed7 --- /dev/null +++ b/cmd/snap/cmd_first_boot.go @@ -0,0 +1,50 @@ +// -*- 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 +// a systemd snapd.firstboot.service job in /etc/systemd/system +// that we did not cleanup. so we need this dummy 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..2c531682 --- /dev/null +++ b/cmd/snap/cmd_get.go @@ -0,0 +1,258 @@ +// -*- 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/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 +`) + +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() { + 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 + + 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) + } +} diff --git a/cmd/snap/cmd_get_base_declaration.go b/cmd/snap/cmd_get_base_declaration.go new file mode 100644 index 00000000..e4b03cf1 --- /dev/null +++ b/cmd/snap/cmd_get_base_declaration.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 main + +import ( + "fmt" + + "github.com/jessevdk/go-flags" +) + +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{} + }, 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{get: true} + }, 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 + if x.get { + err = x.client.DebugGet("base-declaration", &resp, nil) + } else { + err = x.client.Debug("get-base-declaration", nil, &resp) + } + if err != nil { + return err + } + fmt.Fprintf(Stdout, "%s\n", resp.BaseDeclaration) + 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..fdd35317 --- /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/ioutil" + "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, "POST") + c.Check(r.URL.Path, check.Equals, "/v2/debug") + c.Check(r.URL.RawQuery, check.Equals, "") + data, err := ioutil.ReadAll(r.Body) + c.Check(err, check.IsNil) + c.Check(data, check.DeepEquals, []byte(`{"action":"get-base-declaration"}`)) + 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, "") +} + +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 := ioutil.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..c38f2cc5 --- /dev/null +++ b/cmd/snap/cmd_get_test.go @@ -0,0 +1,226 @@ +// -*- 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" + + snapset "github.com/snapcore/snapd/cmd/snap" +) + +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": {}}`) + }) +} 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..9fa287df --- /dev/null +++ b/cmd/snap/cmd_help.go @@ -0,0 +1,365 @@ +// -*- 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 (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) + 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", "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"}, + }, +} + +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..7740e5d8 --- /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..e8e52a92 --- /dev/null +++ b/cmd/snap/cmd_info.go @@ -0,0 +1,762 @@ +// -*- 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 ( + "fmt" + "io" + "path/filepath" + "strconv" + "strings" + "text/tabwriter" + "time" + "unicode" + "unicode/utf8" + + "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 (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 != "" { + wrapGeneric(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 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 +} + +// runesTrimRightSpace returns text, with any trailing whitespace dropped. +func runesTrimRightSpace(text []rune) []rune { + j := len(text) + for j > 0 && unicode.IsSpace(text[j-1]) { + j-- + } + return text[:j] +} + +// runesLastIndexSpace returns the index of the last whitespace rune +// in the text. If the text has no whitespace, returns -1. +func runesLastIndexSpace(text []rune) int { + for i := len(text) - 1; i >= 0; i-- { + if unicode.IsSpace(text[i]) { + return i + } + } + return -1 +} + +// wrapLine wraps a line, assumed to be part of a block-style yaml +// string, to fit into termWidth, preserving the line's indent, and +// writes it out prepending padding to each line. +func wrapLine(out io.Writer, text []rune, pad string, termWidth int) error { + // discard any trailing whitespace + text = runesTrimRightSpace(text) + // establish the indent of the whole block + idx := 0 + for idx < len(text) && unicode.IsSpace(text[idx]) { + idx++ + } + indent := pad + string(text[:idx]) + text = text[idx:] + if len(indent) > termWidth/2 { + // If indent is too big there's not enough space for the actual + // text, in the pathological case the indent can even be bigger + // than the terminal which leads to lp:1828425. + // Rather than let that happen, give up. + indent = pad + " " + } + return wrapGeneric(out, text, indent, indent, termWidth) +} + +// 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 wrapGeneric(out, text, indent, " ", termWidth) +} + +// wrapGeneric wraps the given text to the given width, prefixing the +// first line with indent and the remaining lines with indent2 +func wrapGeneric(out io.Writer, text []rune, indent, indent2 string, termWidth int) error { + // Note: this is _wrong_ for much of unicode (because the width of a rune on + // the terminal is anything between 0 and 2, not always 1 as this code + // assumes) but fixing that is Hard. Long story short, you can get close + // using a couple of big unicode tables (which is what wcwidth + // does). Getting it 100% requires a terminfo-alike of unicode behaviour. + // However, before this we'd count bytes instead of runes, so we'd be + // even more broken. Think of it as successive approximations... at least + // with this work we share tabwriter's opinion on the width of things! + + // This (and possibly printDescr below) should move to strutil once + // we're happy with it getting wider (heh heh) use. + + indentWidth := utf8.RuneCountInString(indent) + delta := indentWidth - utf8.RuneCountInString(indent2) + width := termWidth - indentWidth + + // establish the indent of the whole block + var err error + for len(text) > width && err == nil { + // find a good place to chop the text + idx := runesLastIndexSpace(text[:width+1]) + if idx < 0 { + // there's no whitespace; just chop at line width + idx = width + } + _, err = fmt.Fprint(out, indent, string(text[:idx]), "\n") + // prune any remaining whitespace before the start of the next line + for idx < len(text) && unicode.IsSpace(text[idx]) { + idx++ + } + text = text[idx:] + width += delta + indent = indent2 + delta = 0 + } + if err != nil { + return err + } + _, err = fmt.Fprint(out, indent, string(text), "\n") + return err +} + +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 = wrapLine(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) maybePrintTrackingChannel() { + if iw.localSnap == nil { + return + } + if iw.localSnap.TrackingChannel == "" { + return + } + fmt.Fprintf(iw, "tracking:\t%s\n", iw.localSnap.TrackingChannel) +} + +func (iw *infoWriter) maybePrintInstallDate() { + if iw.localSnap == nil { + return + } + if iw.localSnap.InstallDate.IsZero() { + return + } + fmt.Fprintf(iw, "refresh-date:\t%s\n", iw.fmtTime(iw.localSnap.InstallDate)) +} + +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) maybePrintContact() error { + contact := strings.TrimPrefix(iw.theSnap.Contact, "mailto:") + if contact == "" { + return nil + } + _, err := fmt.Fprintf(iw, "contact:\t%s\n", contact) + return err +} + +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) + return +} + +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.maybePrintContact() + 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.maybePrintInstallDate() + 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..6d5158e8 --- /dev/null +++ b/cmd/snap/cmd_info_test.go @@ -0,0 +1,1223 @@ +// -*- 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" + "io/ioutil" + "net/http" + "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" +) + +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 (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(ioutil.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) TestMaybePrintContact(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.MaybePrintContact(iw) + c.Check(buf.String(), check.Equals, expected, check.Commentf("%q", contact)) + } +} + +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 sanity 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.Fprintln(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.Fprintln(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.Fprintln(w, mockInfoJSON) + 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 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.Fprintln(w, mockInfoJSON) + case 1: + c.Check(r.Method, check.Equals, "GET") + c.Check(r.URL.Path, check.Equals, "/v2/snaps/hello") + fmt.Fprintln(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.Fprintln(w, mockInfoJSON) + case 1: + c.Check(r.Method, check.Equals, "GET") + c.Check(r.URL.Path, check.Equals, "/v2/snaps/hello") + fmt.Fprintln(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.Fprintln(w, mockInfoJSONWithChannels) + case 1, 3, 5: + c.Check(r.Method, check.Equals, "GET") + c.Check(r.URL.Path, check.Equals, "/v2/snaps/hello") + fmt.Fprintln(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{}) + 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-02 +channels: + 1/stable: 2.10 2018-12-18 (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, 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, `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-02 +channels: + 1/stable: 2.10 2018-12-18 (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, 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.Fprintln(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) TestWrapCornerCase(c *check.C) { + // this particular corner case isn't currently reachable from + // printDescr nor printSummary, but best to have it covered + var buf bytes.Buffer + const s = "This is a paragraph indented with leading spaces that are encoded as multiple bytes. All hail EN SPACE." + snap.WrapFlow(&buf, []rune(s), "\u2002\u2002", 30) + c.Check(buf.String(), check.Equals, ` +  This is a paragraph indented + with leading spaces that are + encoded as multiple bytes. + All hail EN SPACE. +`[1:]) +} + +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.Fprintln(w, mockInfoJSONWithChannels) + case 1: + c.Check(r.Method, check.Equals, "GET") + c.Check(r.URL.Path, check.Equals, "/v2/snaps/hello_foo") + fmt.Fprintln(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{}) + // make sure local and remote info is combined in the output + c.Check(s.Stdout(), check.Equals, `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: 2006-01-02 +channels: + 1/stable: 2.10 2018-12-18 (1) 65kB - + 1/candidate: ^ + 1/beta: ^ + 1/edge: ^ +installed: 2.10 (100) 1kB disabled +`) + 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.Fprintln(w, mockInfoJSONWithChannels) + case 1: + c.Check(r.Method, check.Equals, "GET") + c.Check(r.URL.Path, check.Equals, "/v2/snaps/hello") + fmt.Fprintln(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{}) + // make sure local and remote info is combined in the output + 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-02 +channels: + 1/stable: 2.10 2018-12-18 (1) 65kB - + 1/candidate: ^ + 1/beta: ^ + 1/edge: ^ +installed: 2.10 (100) 1kB disabled +`) + 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..e5a5270f --- /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/snapcore/snapd/client" + "github.com/snapcore/snapd/i18n" + + "github.com/jessevdk/go-flags" +) + +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..b5e98d55 --- /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/ioutil" + "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 := ioutil.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 := ioutil.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 := ioutil.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 := ioutil.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 := ioutil.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 := ioutil.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..3c8195e0 --- /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/snapcore/snapd/client" + "github.com/snapcore/snapd/i18n" + + "github.com/jessevdk/go-flags" +) + +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..8a672476 --- /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/ioutil" + "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 := ioutil.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 := ioutil.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 := ioutil.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 := ioutil.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 := ioutil.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 := ioutil.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 := ioutil.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 := ioutil.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 := ioutil.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 := ioutil.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 := ioutil.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 := ioutil.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 := ioutil.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..029ae7ff --- /dev/null +++ b/cmd/snap/cmd_keys.go @@ -0,0 +1,109 @@ +// -*- 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/snapcore/snapd/asserts" + "github.com/snapcore/snapd/i18n" + + "github.com/jessevdk/go-flags" +) + +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 + } + + keys := []Key{} + + manager := asserts.NewGPGKeypairManager() + collect := func(privk asserts.PrivateKey, fpr string, uid string) error { + key := Key{ + Name: uid, + Sha3_384: privk.PublicKey().ID(), + } + keys = append(keys, key) + return nil + } + err := manager.Walk(collect) + if err != nil { + return err + } + 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..00dab5f6 --- /dev/null +++ b/cmd/snap/cmd_keys_test.go @@ -0,0 +1,144 @@ +// -*- 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" + "io/ioutil" + "os" + "path/filepath" + "testing" + + . "gopkg.in/check.v1" + + snap "github.com/snapcore/snapd/cmd/snap" +) + +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 := ioutil.ReadFile(filepath.Join("test-data", fileName)) + c.Assert(err, IsNil) + err = ioutil.WriteFile(filepath.Join(s.tempdir, fileName), data, 0644) + c.Assert(err, IsNil) + } + fakePinentryFn := filepath.Join(s.tempdir, "pinentry-fake") + err := ioutil.WriteFile(fakePinentryFn, fakePinentryData, 0755) + c.Assert(err, IsNil) + gpgAgentConfFn := filepath.Join(s.tempdir, "gpg-agent.conf") + err = ioutil.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) +} + +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..729591bf --- /dev/null +++ b/cmd/snap/cmd_known_test.go @@ -0,0 +1,230 @@ +// -*- 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" + "github.com/snapcore/snapd/store" + + snap "github.com/snapcore/snapd/cmd/snap" +) + +// 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.Fprintln(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.Fprintln(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/.*") // sanity check request + switch n { + case 0: + c.Check(r.Method, check.Equals, "GET") + c.Check(r.URL.Path, check.Equals, "/api/v1/snaps/assertions/model/16/canonical/pi99") + fmt.Fprintln(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/.*") // sanity check request + switch n { + case 0: + c.Check(r.Method, check.Equals, "GET") + c.Check(r.URL.Path, check.Equals, "/api/v1/snaps/assertions/model/16/canonical/pi99") + fmt.Fprintln(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..d92f2fbf --- /dev/null +++ b/cmd/snap/cmd_list.go @@ -0,0 +1,134 @@ +// -*- 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 (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, + 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..0cdd9efb --- /dev/null +++ b/cmd/snap/cmd_list_test.go @@ -0,0 +1,256 @@ +// -*- 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} +]}`) + 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.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..707a81f7 --- /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 +purchasing of snaps using 'snap buy', 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.Fprint(Stdout, fmt.Sprintf(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..a4f3e1e4 --- /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/ioutil" + "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 := ioutil.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..3491d87a --- /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/snapcore/snapd/i18n" + + "github.com/jessevdk/go-flags" +) + +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..5107ecc2 --- /dev/null +++ b/cmd/snap/cmd_model.go @@ -0,0 +1,337 @@ +// -*- 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" + "strings" + "time" + + "github.com/jessevdk/go-flags" + + "github.com/snapcore/snapd/asserts" + "github.com/snapcore/snapd/client" + "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. +`) + + invalidTypeMessage = i18n.G("invalid type for %q header") + 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")) + + // this list is a "nice" "human" "readable" "ordering" of headers to print + // off, sorted in lexical order with meta headers and primary key headers + // removed, and big nasty keys such as device-key-sha3-384 and + // device-key at the bottom + // 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", + "device-key-sha3-384", + "device-key", + } +) + +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 + } + + var mainAssertion asserts.Assertion + 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.Serial { + mainAssertion = serialAssertion + } else { + mainAssertion = modelAssertion + } + + 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 + } + + _, err := Stdout.Write(asserts.Encode(mainAssertion)) + return err + } + + termWidth, _ := termSize() + termWidth -= 3 + if termWidth > 100 { + // any wider than this and it gets hard to read + termWidth = 100 + } + + esc := x.getEscapes() + + 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 + } + + // 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 !x.Verbose && !x.Serial { + separator = "" + } + + // ordering of the primary keys for model: brand, model, serial + // ordering of primary keys for serial is brand-id, model, serial + + // output brand/brand-id + brandIDHeader := mainAssertion.HeaderString("brand-id") + modelHeader := mainAssertion.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 client.IsAssertionNotFoundError(serialErr) { + if x.Verbose || x.Serial { + // verbose and serial are yamlish, so we need to escape the dash + serial = esc.dash + } 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 x.Serial || x.Verbose { + fmt.Fprintf(w, "brand-id:\t%s\n", brandIDHeader) + fmt.Fprintf(w, "model:\t%s\n", modelHeader) + } else { + // for the model command (not --serial) we want to show a publisher + // style display of "brand" instead of just "brand-id" + storeAccount, err := x.client.StoreAccount(brandIDHeader) + if err != nil { + return err + } + // 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, longPublisher(x.getEscapes(), storeAccount)) + + // 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) + } + + // only output the grade if it is non-empty, either it is not in the model + // assertion for all non-uc20 model assertions, or it is non-empty and + // required for uc20 model assertions + grade := modelAssertion.HeaderString("grade") + if grade != "" { + fmt.Fprintf(w, "grade%s\t%s\n", separator, grade) + } + + // serial is same for all variants + fmt.Fprintf(w, "serial%s\t%s\n", separator, serial) + + // --verbose means output more information + if x.Verbose { + allHeadersMap := mainAssertion.Headers() + + for _, headerName := range niceOrdering { + invalidTypeErr := fmt.Errorf(invalidTypeMessage, headerName) + + 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 { + return invalidTypeErr + } + if len(headerIfaceList) == 0 { + continue + } + fmt.Fprintf(w, "%s:\t\n", headerName) + for _, elem := range headerIfaceList { + headerStringElem, ok := elem.(string) + if !ok { + return invalidTypeErr + } + // 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 with fmtTime from the timeMixin + case "timestamp": + timestamp, ok := headerValue.(string) + if !ok { + return invalidTypeErr + } + + // 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", x.fmtTime(t)) + + // 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 invalidTypeErr + } + + switch { + case termWidth > 86: + fmt.Fprintf(w, "device-key-sha3-384: %s\n", headerString) + case termWidth <= 86 && termWidth > 66: + fmt.Fprintln(w, "device-key-sha3-384: |") + wrapLine(w, []rune(headerString), " ", termWidth) + } + + // long base64 key we can rewrap safely + case "device-key": + headerString, ok := headerValue.(string) + if !ok { + return invalidTypeErr + } + // 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 split by newlines and re-wrap to make it + // prettier + headerString = strings.Join( + strings.Split(headerString, "\n"), + "") + fmt.Fprintln(w, "device-key: |") + wrapLine(w, []rune(headerString), " ", termWidth) + + // the default is all the rest of short scalar values, which all + // should be strings + default: + headerString, ok := headerValue.(string) + if !ok { + return invalidTypeErr + } + fmt.Fprintf(w, "%s:\t%s\n", headerName, headerString) + } + } + } + + 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..c1abad2f --- /dev/null +++ b/cmd/snap/cmd_model_test.go @@ -0,0 +1,593 @@ +// -*- 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 +grade: secured +snaps: + - + default-channel: 20/edge + id: UqFziVZDHLSyO3TqSWgNBoAdHbLI4dAH + name: pc + type: gadget + - + default-channel: 20/edge + id: pYVQrBcKmBa0mZ4CCN7ExT6jH8rY1hza + name: pc-kernel + type: kernel + - + 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 secured +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) 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..11f2ac42 --- /dev/null +++ b/cmd/snap/cmd_pack.go @@ -0,0 +1,125 @@ +// -*- 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" + "path/filepath" + + "golang.org/x/xerrors" + + "github.com/jessevdk/go-flags" + + "github.com/snapcore/snapd/i18n" + "github.com/snapcore/snapd/snap" + "github.com/snapcore/snapd/snap/pack" + + // for SanitizePlugsSlots + "github.com/snapcore/snapd/interfaces/builtin" +) + +type packCmd struct { + CheckSkeleton bool `long:"check-skeleton"` + Filename string `long:"filename"` + Compression string `long:"compression" hidden:"yes"` + 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. +`) + +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)"), + }, 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.Snap(x.Positional.SnapDir, &pack.Options{ + TargetDir: x.Positional.TargetDir, + SnapName: x.Filename, + Compression: x.Compression, + }) + 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..d7c33d7e --- /dev/null +++ b/cmd/snap/cmd_pack_test.go @@ -0,0 +1,142 @@ +package main_test + +import ( + "fmt" + "io/ioutil" + "os" + "path/filepath" + + "gopkg.in/check.v1" + + snaprun "github.com/snapcore/snapd/cmd/snap" + "github.com/snapcore/snapd/logger" +) + +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 = ioutil.WriteFile(filepath.Join(metaDir, "snap.yaml"), []byte(snapYaml), 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 = ioutil.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)) + } +} 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..370c853a --- /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/snapcore/snapd/i18n" + + "github.com/jessevdk/go-flags" +) + +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..86d28315 --- /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..4f6c695e --- /dev/null +++ b/cmd/snap/cmd_prepare_image.go @@ -0,0 +1,121 @@ +// -*- 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 main + +import ( + "os" + "strings" + + "github.com/jessevdk/go-flags" + + "github.com/snapcore/snapd/i18n" + "github.com/snapcore/snapd/image" +) + +type cmdPrepareImage struct { + Classic bool `long:"classic"` + Architecture string `long:"arch"` + + Positional struct { + ModelAssertionFn string + TargetDir string + } `positional-args:"yes" required:"yes"` + + Channel string `long:"channel"` + // TODO: introduce SnapWithChannel? + Snaps []string `long:"snap" value-name:"[=]"` + ExtraSnaps []string `long:"extra-snaps" hidden:"yes"` // DEPRECATED +} + +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. + "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. + "channel": i18n.G("The channel to use"), + }, []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 + +func (x *cmdPrepareImage) Execute(args []string) error { + opts := &image.Options{ + Snaps: x.ExtraSnaps, + ModelFile: x.Positional.ModelAssertionFn, + Channel: x.Channel, + Architecture: x.Architecture, + } + + 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 + + return imagePrepare(opts) +} diff --git a/cmd/snap/cmd_prepare_image_test.go b/cmd/snap/cmd_prepare_image_test.go new file mode 100644 index 00000000..2b16498c --- /dev/null +++ b/cmd/snap/cmd_prepare_image_test.go @@ -0,0 +1,141 @@ +// -*- 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" + "os" + + snap "github.com/snapcore/snapd/cmd/snap" + "github.com/snapcore/snapd/image" +) + +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 := snap.MockImagePrepare(prep) + defer r() + + rest, err := snap.Parser(snap.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 := snap.MockImagePrepare(prep) + defer r() + + rest, err := snap.Parser(snap.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 := snap.MockImagePrepare(prep) + defer r() + + rest, err := snap.Parser(snap.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 := snap.MockImagePrepare(prep) + defer r() + + os.Setenv("UBUNTU_STORE_COHORT_KEY", "is-six-centuries") + + rest, err := snap.Parser(snap.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 := snap.MockImagePrepare(prep) + defer r() + + rest, err := snap.Parser(snap.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"}, + }) +} diff --git a/cmd/snap/cmd_reboot.go b/cmd/snap/cmd_reboot.go new file mode 100644 index 00000000..9526146a --- /dev/null +++ b/cmd/snap/cmd_reboot.go @@ -0,0 +1,125 @@ +// -*- 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"` +} + +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 system label 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 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"), + }, []argDesc{ + { + // TRANSLATORS: This needs to begin with < and end with > + name: i18n.G("