diff --git a/TODO.md b/TODO.md index 23edf2519e8..d789f86ea61 100644 --- a/TODO.md +++ b/TODO.md @@ -156,8 +156,6 @@ SPDX-License-Identifier: LGPL-2.1-or-later - make vpick assessment counting work -- sysext: condition confext/sysext on machine tags, too - - sysupdate: add health check logic, revert updates if fail - confext/sysext: add policy file concept: json files that encode for relevant @@ -1229,9 +1227,6 @@ SPDX-License-Identifier: LGPL-2.1-or-later - complete varlink interface - download images into .v/ dirs -- importd: support image signature verification with PKCS#7 + OpenBSD signify - logic, as alternative to crummy gpg - - In .socket units, add ConnectStream=, ConnectDatagram=, ConnectSequentialPacket= that create a socket, and then *connect to* rather than listen on some socket. Then, add a new setting WriteData= that takes some diff --git a/man/sysupdate.d.xml b/man/sysupdate.d.xml index ee4d0de04dd..8e899837e92 100644 --- a/man/sysupdate.d.xml +++ b/man/sysupdate.d.xml @@ -140,7 +140,10 @@ For HTTP/HTTPS sources a simple scheme is used: a manifest file SHA256SUMS, following the format defined by sha256sum1, - lists file names and their SHA256 hashes. + lists file names and their SHA256 hashes. Alternatively, a Uapi16Manifest file + following the UAPI.16 File + Manifest specification may be used, see ManifestFormat= + below. Transfers are done in the alphabetical order of the .transfer file names they are @@ -521,6 +524,24 @@ keyring in /etc/systemd/import-pubring.pgp (or, if that does not exist, /etc/systemd/import-pubring.gpg). + For UAPI.16 manifests (see ManifestFormat=) the signature is embedded in + the manifest itself, as a signature object. Only PKCS#7 signatures are supported, which must have + been made with the private key of one of the X.509 certificates found in the File + Hierarchy for the Verification of OS Artifacts (VOA), i.e. in + OS/manifest/sysupdate/x509/NAME-certificate.pem + below the VOA load paths /etc/voa/, /run/voa/, + /usr/local/share/voa/ and /usr/share/voa/, where + OS is derived from + os-release5 of + the system (or of the root directory or image specified with or + ). Only artifact verifiers are supported, i.e. certificates are not checked + against trust anchors, they are trusted as they are. If no certificates are found the transfer + fails. Such signatures may be generated with systemd-manifest make --private-key=, + and checked against the same certificates with systemd-manifest verify + --voa-context=sysupdate, see + systemd-manifest1. + This option is essential to provide integrity guarantees for downloaded resources and thus should be left enabled, outside of test environments. @@ -634,11 +655,12 @@ Specifies where to find source versions of this resource. If the source type is selected as url-file or - url-tar this must be a HTTP/HTTPS URL. The URL is suffixed with - /SHA256SUMS to acquire the manifest file, with - /SHA256SUMS.gpg to acquire the detached signature file for it, and with the file - names listed in the manifest file in case an update is executed and a resource shall be - downloaded. + url-tar this must be a HTTP/HTTPS URL. Unless ManifestPath= + is specified, the URL is suffixed with /SHA256SUMS (and + /SHA256SUMS.gpg for the detached signature file) or with + /Uapi16Manifest (which carries its signature inline), depending on + ManifestFormat=, to acquire the manifest file. It is suffixed with the file names + listed in the manifest file in case an update is executed and a resource shall be downloaded. Alternatively, for these two source types a URL of the form provider:[socket]/resource @@ -698,8 +720,49 @@ url-file or url-tar source. Takes sha256sums, which selects a SHA256SUMS file as generated by sha256sum1, - accompanied by a detached SHA256SUMS.gpg signature, see above. Defaults to - sha256sums. This option has no effect for other source resource types. + accompanied by a detached SHA256SUMS.gpg signature, see above. Or + uapi16, which selects a Uapi16Manifest file following the + UAPI.16 File + Manifest specification, i.e. a JSON-SEQ stream with one object per file, carrying the file + name, size and SHA256 digest of each file, as generated by + systemd-manifest1 + or systemd-dissect --uapi16. Only regular files listed in the manifest are + considered, and entries whose GPT partition type does not match the target's + MatchPartitionType= are ignored. The signatures embedded in UAPI.16 manifests are + verified against the certificates found in the VOA hierarchy, see + Verify= above. Defaults to sha256sums. This option has no + effect for other source resource types. + + If a file object of a UAPI.16 manifest carries a contents array, the file + is acquired from the sources listed there, instead of by its name relative to + Path=. Each entry is an alternative location of the same data, tried in the listed + order until one works: entries with a file field are downloaded from that + (relative) file name resolved against Path=, entries with a + url field from that URL, and entries with a literal field carry + the data inline. Entries with none of these three fields refer to a file of the object's name, + resolved against Path=. Relative file names are resolved against + Path= even if ManifestPath= is set, as the latter only + redirects the acquisition of the manifest itself. The encoding field + may be one of gzip, xz, zstd, + bzip2 or lz4, in which case the data is decompressed on + acquisition. The declared encoding is binding: the data is + strictly decoded with that algorithm (or taken as is, if none is declared), no detection of the + compression format from the data takes place. A source may carry more than the file, in which case + the range of the decoded data starting at offset and extending over the file's + size is used. Downloaded sources are verified against their + encodedSha256 digest if they carry one, except for sources that are not encoded + but carry more than the file: of those only the range that makes up the file is downloaded (via an + HTTP range request), and their encodedSha256 digest is not checked. If the source + is encoded, carries more than the file, or has no digest of its own, the decoded file is checked + against the file's sha256 digest. Downloaded sources of url-tar + resources, as well as inline data that is encoded, must consist of the file and nothing else, other + such sources are ignored. + + The extended attributes in the user namespace listed in the + extendedAttributes field of a file object of a UAPI.16 manifest are applied to the + file or directory created when installing it. This is done on a best-effort basis: if the file system + does not support extended attributes, or they cannot be set for other reasons, the instance is + installed without them. @@ -724,6 +787,136 @@ + + + MatchXAttr= + + Takes a boolean argument, defaults to on. If enabled, source instances may be + restricted to specific systems via extended attributes in the user namespace. Each + of the following extended attributes is checked against a property of the system that is being + updated (i.e. the system specified with /, if + used): + + + Extended attributes checked + + + + + + Extended attribute + Matched against + + + + + user.uapi.match_hostname + The hostname, as configured in /etc/hostname if operating on a + root directory or image, the current hostname otherwise. + + + user.uapi.match_machine_id + The machine ID, see + machine-id5, + formatted as 32 lowercase hexadecimal characters. + + + user.uapi.match_machine_tags + The machine tags, see TAGS= in + machine-info5. + + + user.uapi.match_image_id + IMAGE_ID= from + os-release5. + + + user.uapi.match_image_version + IMAGE_VERSION= from + os-release5. + + + user.uapi.match_os_id + ID= from + os-release5. + + + user.uapi.match_os_version + VERSION_ID= from + os-release5. + + + +
+ + Each extended attribute contains a list of entries, separated by NUL bytes. A value without + any NUL byte is a list with a single entry. For all extended attributes but the two version ones, + each entry is a shell-style glob pattern (see + fnmatch3), + and the instance matches if any of the entries matches the system property. + For user.uapi.match_machine_tags the instance matches if any entry matches any of + the machine's tags. For user.uapi.match_image_version and + user.uapi.match_os_version each entry is a version comparison expression, i.e. + a version prefixed with one of <, <=, + =, !=, >=, > + (or a glob pattern, optionally prefixed with $= or !$=), the + same syntax ConditionVersion= in + systemd.unit5 + accepts, and the instance matches if all of the expressions hold. + + An instance that does not carry the extended attribute is not restricted by it. If the system + property is not set (for example, if /etc/os-release carries no + IMAGE_ID=), an instance that carries the extended attribute does not match. An + instance whose extended attribute cannot be parsed does not match either. If multiple of these + extended attributes are set, instances must match all of them. Instances that do not + match are ignored, as if they did not exist. The system properties are only read once an instance + actually carries one of these extended attributes. + + The extended attributes are taken from the extendedAttributes field of the + file objects in UAPI.16 manifests (see ManifestFormat=), as well as from the files + and directories in the file system, for local sources. When an instance is installed from a source + with a UAPI.16 manifest, the user extended attributes listed in the manifest are + applied to the file or directory created (see ManifestFormat= above), hence the + restrictions are retained. Installed instances are checked only if MatchXAttr= is + enabled in the [Target] section too, see below. + + This setting is intended to make specific versions unavailable to certain systems + altogether, for example to offer a build only to certain machines, or only to systems of a certain + image type. Instances that do not match are simply not candidates for the system: the newest version + with matching instances is picked instead. It is not intended as a mechanism to make systems pass + through specific intermediary versions while updating, nor to offer different builds of the same + version to different systems. + + Note that a version is only offered as update if all transfers have a matching instance of + it. If no instance of a version matches for one transfer, the whole version is considered incomplete + and is not offered at all. Hence, the extended attributes should typically be applied to the + instances of all transfers of a version alike. This also means that this setting cannot be used to + make a transfer optional. To make parts of an update optional, use Features= + instead, see above. + + Example: the extended attribute of an instance suitable for any system running Fedora 41 or + newer (but older than 50) may be set with: + + # setfattr -n user.uapi.match_os_version -v '0x3e3d3431003c353000' foo_7.raw + + (This sets the NUL separated list >=41, <50.) + Combined with user.uapi.match_os_id set to fedora, only Fedora + systems of a suitable version will consider the instance (as long as + MatchXAttr= is not turned off in the [Source] section of the transfer + file). + + Example: in order to offer version 8 to machines tagged canary before + everybody else, set the extended attribute on its instance with: + + # setfattr -n user.uapi.match_machine_tags -v canary foo_8.raw + + Machines that carry the canary tag (see TAGS= in + machine-info5) + will update to version 8, while all others will skip it and stay at (or update to) version 7. Once + version 8 shall be offered to everybody, the extended attribute is simply removed again. + +
+
@@ -851,6 +1044,20 @@ + + MatchXAttr= + + Takes a boolean argument, defaults to off (unlike the setting of the same name in + the [Source] section, which defaults to on). Works like that (see above), but applies to installed + instances, whose extended attributes are read from the installed files and directories. Installed instances that do not match + are ignored, as if they did not exist: they are neither considered when determining the currently + installed version, nor removed to make room for new instances (see + InstancesMax=). Not supported for partition targets, as + partitions do not carry extended attributes. + + + + PartitionUUID= PartitionFlags= diff --git a/src/shared/data-fd-util.c b/src/shared/data-fd-util.c index 6b1041e0f3c..e146a8424fe 100644 --- a/src/shared/data-fd-util.c +++ b/src/shared/data-fd-util.c @@ -18,6 +18,21 @@ /* If memfd didn't work out, then let's use a file in /tmp up to a size of 1M. If it's large than that use /var/tmp/ instead. */ #define DATA_FD_TMP_LIMIT (1U * U64_MB) +static int memfd_seal_and_reopen(int fd, int mode) { + int r; + + assert(fd >= 0); + + /* Seals the memfd and returns a new fd to it with the specified access mode, positioned at the + * beginning of the file. */ + + r = memfd_set_sealed(fd); + if (r < 0) + return r; + + return fd_reopen(fd, mode); +} + int copy_data_fd_full(int fd, uint64_t max_size) { _cleanup_close_ int copy_fd = -EBADF, tmp_fd = -EBADF; const char *td; @@ -67,20 +82,16 @@ int copy_data_fd_full(int fd, uint64_t max_size) { if (remaining != UINT64_MAX) remaining -= n; + if (r == 0) + /* Did it fit into the limit? If so, we are done. (Reopening positions the new fd + * at the beginning of the file, no need to seek.) */ + return memfd_seal_and_reopen(copy_fd, O_RDONLY); + off_t f = lseek(copy_fd, 0, SEEK_SET); if (f < 0) return -errno; if (f != 0) return -EIO; - - if (r == 0) { - /* Did it fit into the limit? If so, we are done. */ - r = memfd_set_sealed(copy_fd); - if (r < 0) - return r; - - return TAKE_FD(copy_fd); - } } /* If we have reason to believe this will fit fine in /tmp, then use that as first fallback. */ @@ -223,13 +234,8 @@ int memfd_clone_fd(int fd, const char *name, int mode) { if (r < 0) return r; - if (ro) { - r = memfd_set_sealed(mfd); - if (r < 0) - return r; - - return fd_reopen(mfd, mode); - } + if (ro) + return memfd_seal_and_reopen(mfd, mode); off_t f = lseek(mfd, 0, SEEK_SET); if (f < 0) @@ -237,3 +243,20 @@ int memfd_clone_fd(int fd, const char *name, int mode) { return TAKE_FD(mfd); } + +int data_fd_from_iovec(const char *name, const struct iovec *iov) { + _cleanup_close_ int fd = -EBADF; + + /* Places the specified data in a sealed memfd and returns a read-only fd to it, positioned at the + * beginning of the file. This is useful for passing literal data to other processes, for example as + * standard input. Unlike copy_data_fd() this never falls back to temporary files, the data is in + * memory already after all. */ + + assert(iov); + + fd = memfd_new_and_seal(name, iov->iov_base, iov->iov_len); + if (fd < 0) + return fd; + + return fd_reopen(fd, O_RDONLY); +} diff --git a/src/shared/data-fd-util.h b/src/shared/data-fd-util.h index 9a9f0c8ed46..570f8b7c9a8 100644 --- a/src/shared/data-fd-util.h +++ b/src/shared/data-fd-util.h @@ -10,3 +10,4 @@ static inline int copy_data_fd(int fd) { int seekable_data_fd(int fd, uint64_t max_size); int open_seekable_data_fd(int dir_fd, const char *path, uint64_t max_size); int memfd_clone_fd(int fd, const char *name, int mode); +int data_fd_from_iovec(const char *name, const struct iovec *iov); diff --git a/src/shared/forward.h b/src/shared/forward.h index d8b7b186dd9..3d9a1c03d6c 100644 --- a/src/shared/forward.h +++ b/src/shared/forward.h @@ -95,7 +95,9 @@ typedef struct Table Table; typedef struct Tpm2Context Tpm2Context; typedef struct Tpm2Handle Tpm2Handle; typedef struct Tpm2PCRValue Tpm2PCRValue; +typedef struct Uapi16Contents Uapi16Contents; typedef struct Uapi16Object Uapi16Object; +typedef struct Uapi16Xattr Uapi16Xattr; typedef struct UnitInfo UnitInfo; typedef struct UnitResult UnitResult; typedef struct UserRecord UserRecord; diff --git a/src/shared/uapi16-manifest.c b/src/shared/uapi16-manifest.c index bff48145756..974fd7e1753 100644 --- a/src/shared/uapi16-manifest.c +++ b/src/shared/uapi16-manifest.c @@ -536,7 +536,7 @@ static int json_dispatch_contents(const char *name, sd_json_variant *variant, sd if (r < 0) return r; - if (!!c.file + !!c.url + !!iovec_is_set(&c.literal) > 1) + if (!!c.file + !!c.url + uapi16_contents_has_literal(&c) > 1) return json_log(variant, flags, SYNTHETIC_ERRNO(EINVAL), "Only one of 'file', 'url', 'literal' may be set for '%s'.", strna(name)); if (c.encoding) { diff --git a/src/shared/uapi16-manifest.h b/src/shared/uapi16-manifest.h index 55d6a7b1dcd..38746e17661 100644 --- a/src/shared/uapi16-manifest.h +++ b/src/shared/uapi16-manifest.h @@ -26,6 +26,13 @@ typedef struct Uapi16Contents { .original_size = UINT64_MAX, \ } +static inline bool uapi16_contents_has_literal(const Uapi16Contents *c) { + /* An empty "literal" field is valid (it encodes empty data), hence check whether the field was set, + * not whether it carries data: when decoding Base64 we always allocate a buffer, even for empty + * data. */ + return c->literal.iov_base; +} + /* An extended attribute in the "user" namespace */ typedef struct Uapi16Xattr { char *name; /* The attribute name, without the "user." prefix */ diff --git a/src/shared/varlink-io.systemd.SysUpdate.Provider.c b/src/shared/varlink-io.systemd.SysUpdate.Provider.c index e1b41750f45..d0d56f5f028 100644 --- a/src/shared/varlink-io.systemd.SysUpdate.Provider.c +++ b/src/shared/varlink-io.systemd.SysUpdate.Provider.c @@ -53,7 +53,9 @@ static SD_VARLINK_DEFINE_ENUM_TYPE( static SD_VARLINK_DEFINE_ENUM_TYPE( ManifestFormat, SD_VARLINK_FIELD_COMMENT("A SHA256SUMS file, see ManifestFormat=sha256sums in sysupdate.d(5)."), - SD_VARLINK_DEFINE_ENUM_VALUE(sha256sums)); + SD_VARLINK_DEFINE_ENUM_VALUE(sha256sums), + SD_VARLINK_FIELD_COMMENT("A UAPI.16 manifest, see ManifestFormat=uapi16 in sysupdate.d(5)."), + SD_VARLINK_DEFINE_ENUM_VALUE(uapi16)); static SD_VARLINK_DEFINE_STRUCT_TYPE( TransferSource, @@ -68,7 +70,9 @@ static SD_VARLINK_DEFINE_STRUCT_TYPE( SD_VARLINK_FIELD_COMMENT("The format of the manifest file listing the available source files, see ManifestFormat= in the [Source] section of sysupdate.d(5). If unset, defaults to 'sha256sums'."), SD_VARLINK_DEFINE_FIELD_BY_TYPE(manifestFormat, ManifestFormat, SD_VARLINK_NULLABLE), SD_VARLINK_FIELD_COMMENT("The URL of the manifest file, see ManifestPath= in the [Source] section of sysupdate.d(5). If unset, the manifest is acquired from the source URL, under the file name appropriate for the manifest format. No specifier expansion is applied."), - SD_VARLINK_DEFINE_FIELD(manifestPath, SD_VARLINK_STRING, SD_VARLINK_NULLABLE)); + SD_VARLINK_DEFINE_FIELD(manifestPath, SD_VARLINK_STRING, SD_VARLINK_NULLABLE), + SD_VARLINK_FIELD_COMMENT("Whether to match the user.uapi.match_* extended attributes of instances against the local system, see MatchXAttr= in the [Source] section of sysupdate.d(5). If unset, defaults to true."), + SD_VARLINK_DEFINE_FIELD(matchXAttr, SD_VARLINK_BOOL, SD_VARLINK_NULLABLE)); static SD_VARLINK_DEFINE_STRUCT_TYPE( TransferTarget, @@ -105,7 +109,9 @@ static SD_VARLINK_DEFINE_STRUCT_TYPE( SD_VARLINK_FIELD_COMMENT("Whether to remove temporary files from previous runs, see RemoveTemporary= in the [Target] section of sysupdate.d(5)."), SD_VARLINK_DEFINE_FIELD(removeTemporary, SD_VARLINK_BOOL, SD_VARLINK_NULLABLE), SD_VARLINK_FIELD_COMMENT("The path of a symlink to maintain pointing to the newest instance, see CurrentSymlink= in the [Target] section of sysupdate.d(5). No specifier expansion is applied."), - SD_VARLINK_DEFINE_FIELD(currentSymlink, SD_VARLINK_STRING, SD_VARLINK_NULLABLE)); + SD_VARLINK_DEFINE_FIELD(currentSymlink, SD_VARLINK_STRING, SD_VARLINK_NULLABLE), + SD_VARLINK_FIELD_COMMENT("Whether to match the user.uapi.match_* extended attributes of instances against the local system, see MatchXAttr= in the [Target] section of sysupdate.d(5). If unset, defaults to false."), + SD_VARLINK_DEFINE_FIELD(matchXAttr, SD_VARLINK_BOOL, SD_VARLINK_NULLABLE)); static SD_VARLINK_DEFINE_STRUCT_TYPE( Transfer, diff --git a/src/sysupdate/sysupdate-instance.c b/src/sysupdate/sysupdate-instance.c index 91b5d988d99..ef769bac103 100644 --- a/src/sysupdate/sysupdate-instance.c +++ b/src/sysupdate/sysupdate-instance.c @@ -3,6 +3,7 @@ #include "alloc-util.h" #include "log.h" #include "sysupdate-instance.h" +#include "uapi16-manifest.h" void instance_metadata_destroy(InstanceMetadata *m) { assert(m); @@ -58,5 +59,11 @@ Instance *instance_free(Instance *i) { free(i->path); partition_info_destroy(&i->partition_info); + FOREACH_ARRAY(s, i->sources, i->n_sources) + uapi16_contents_done(s); + free(i->sources); + + uapi16_xattr_array_free(i->user_xattrs, i->n_user_xattrs); + return mfree(i); } diff --git a/src/sysupdate/sysupdate-instance.h b/src/sysupdate/sysupdate-instance.h index b482ab3b2cd..584937159e4 100644 --- a/src/sysupdate/sysupdate-instance.h +++ b/src/sysupdate/sysupdate-instance.h @@ -8,8 +8,8 @@ #include "sysupdate-partition.h" typedef struct InstanceMetadata { - /* Various bits of metadata for each instance, that is either derived from the filename/GPT label or - * from metadata of the file/partition itself */ + /* Various bits of metadata for each instance, that is either derived from the filename/GPT label, + * from metadata of the file/partition itself or from the manifest */ char *version; sd_id128_t partition_uuid; bool partition_uuid_set; @@ -49,6 +49,16 @@ struct Instance { char *path; /* includes the `.sysupdate.partial.` (etc.) prefix, if applicable */ PartitionInfo partition_info; + /* The sources we can acquire the resource from (for web backed sources) */ + Uapi16Contents *sources; + size_t n_sources; + + /* The "user" extended attributes listed in the manifest (for web backed sources), which we apply + * to the inode we create when installing the instance */ + Uapi16Xattr *user_xattrs; + size_t n_user_xattrs; + + /* The current state of this instance */ bool is_partial; bool is_pending; }; diff --git a/src/sysupdate/sysupdate-resource.c b/src/sysupdate/sysupdate-resource.c index c75f9fdd10f..1744b113822 100644 --- a/src/sysupdate/sysupdate-resource.c +++ b/src/sysupdate/sysupdate-resource.c @@ -8,6 +8,7 @@ #include "blockdev-util.h" #include "build-path.h" #include "chase.h" +#include "compress.h" #include "device-util.h" #include "dirent-util.h" #include "env-util.h" @@ -37,6 +38,9 @@ #include "sysupdate-pattern.h" #include "sysupdate-resource.h" #include "time-util.h" +#include "uapi16-manifest.h" +#include "web-util.h" +#include "xattr-match.h" void resource_destroy(Resource *rr) { assert(rr); @@ -62,6 +66,12 @@ bool resource_has_glob_directory_pattern(Resource *rr) { return false; } +unsigned resource_xattr_match_mask(const Resource *rr) { + assert(rr); + + return rr->match_xattr ? XATTR_MATCH_MASK_ALL : 0; +} + static int resource_add_instance( Resource *rr, const char *path, @@ -98,7 +108,8 @@ static int resource_load_from_directory_recursive( const char* relpath_for_matching, mode_t m, bool ancestor_is_partial, - bool ancestor_is_pending) { + bool ancestor_is_pending, + const XAttrMatchFilter *filter) { int r; for (;;) { @@ -203,7 +214,7 @@ static int resource_load_from_directory_recursive( return log_error_errno(errno, "Failed to open directory %s/%s: %m", rr->path, rel_joined); } - r = resource_load_from_directory_recursive(rr, subdir, rel_joined, rel_joined_for_matching, m, is_partial, is_pending); + r = resource_load_from_directory_recursive(rr, subdir, rel_joined, rel_joined_for_matching, m, is_partial, is_pending, filter); if (r < 0) return r; continue; @@ -212,6 +223,13 @@ static int resource_load_from_directory_recursive( if (S_ISDIR(st.st_mode) && m != S_IFDIR) continue; + /* Skip instances restricted to other systems via extended attributes */ + r = xattr_match_filter_test_inode(filter, dirfd(d), de->d_name); + if (r < 0) + return log_error_errno(r, "Failed to check extended attributes of '%s': %m", de->d_name); + if (r == 0) + continue; + joined = path_join(rr->path, rel_joined); if (!joined) return log_oom(); @@ -239,7 +257,8 @@ static int resource_load_from_directory_recursive( static int resource_load_from_directory( Resource *rr, - mode_t m) { + mode_t m, + const XAttrMatchFilter *filter) { _cleanup_closedir_ DIR *d = NULL; assert(rr); @@ -256,7 +275,15 @@ static int resource_load_from_directory( return log_error_errno(errno, "Failed to open directory '%s': %m", rr->path); } - return resource_load_from_directory_recursive(rr, d, /* relpath= */ NULL, /* relpath_for_matching= */ NULL, m, /* ancestor_is_partial= */ false, /* ancestor_is_pending= */ false); + return resource_load_from_directory_recursive( + rr, + d, + /* relpath= */ NULL, + /* relpath_for_matching= */ NULL, + m, + /* ancestor_is_partial= */ false, + /* ancestor_is_pending= */ false, + filter); } static int resource_load_from_blockdev(Resource *rr) { @@ -361,11 +388,22 @@ static int resource_load_from_blockdev(Resource *rr) { return 0; } +static const char* const resource_manifest_filename_table[_MANIFEST_FORMAT_MAX] = { + [MANIFEST_FORMAT_SHA256SUMS] = "SHA256SUMS", + [MANIFEST_FORMAT_UAPI16] = UAPI16_MANIFEST_FILENAME, +}; + +DEFINE_PRIVATE_STRING_TABLE_LOOKUP_TO_STRING(resource_manifest_filename, ManifestFormat); + +static bool resource_manifest_has_inline_signatures(ManifestFormat style) { + return style == MANIFEST_FORMAT_UAPI16; +} + static int resource_manifest_url(Resource *rr, char **ret) { int r; assert(rr); - assert(rr->manifest_format == MANIFEST_FORMAT_SHA256SUMS); + assert(IN_SET(rr->manifest_format, MANIFEST_FORMAT_SHA256SUMS, MANIFEST_FORMAT_UAPI16)); assert(ret); /* Returns the URL to acquire the manifest from: either the one explicitly configured, or the @@ -379,9 +417,10 @@ static int resource_manifest_url(Resource *rr, char **ret) { return 0; } - r = import_url_append_component(rr->path, "SHA256SUMS", ret); + const char *fn = resource_manifest_filename_to_string(rr->manifest_format); + r = import_url_append_component(rr->path, fn, ret); if (r < 0) - return log_error_errno(r, "Failed to append SHA256SUMS to URL: %m"); + return log_error_errno(r, "Failed to append '%s' to URL '%s': %m", fn, rr->path); return 0; } @@ -394,11 +433,11 @@ static int download_manifest( int r; - assert(manifest_format == MANIFEST_FORMAT_SHA256SUMS); + assert(IN_SET(manifest_format, MANIFEST_FORMAT_SHA256SUMS, MANIFEST_FORMAT_UAPI16)); assert(manifest_url); assert(ret); - /* Download a SHA256SUMS file as manifest */ + /* Download a SHA256SUMS/Uapi16Manifest file as manifest */ _cleanup_close_pair_ int pfd[2] = EBADF_PAIR; if (pipe2(pfd, O_CLOEXEC) < 0) @@ -424,7 +463,7 @@ static int download_manifest( "raw", "--direct", /* just download the specified URL, don't download anything else */ "--checksum-file=yes", /* whatever its name, look for .gpg/.asc signature */ - "--verify", verify_signature ? "signature" : "no", /* verify the manifest file */ + "--verify", verify_signature && !resource_manifest_has_inline_signatures(manifest_format) ? "signature" : "no", /* verify the manifest file */ manifest_url, "-", /* write to stdout */ NULL @@ -626,10 +665,318 @@ static int sha256sums_process( } } +static int instance_copy_uapi16(Instance *instance, const Uapi16Object *object) { + assert(instance); + assert(object); + + /* We verified earlier that these fields are definitely set in the UAPI 16 objects */ + assert(object->type == S_IFREG); + assert(iovec_is_set(&object->sha256)); + assert(object->size != UINT64_MAX); + + if (instance->metadata.sha256sum_set) { + if (memcmp_nn(instance->metadata.sha256sum, SHA256_DIGEST_SIZE, object->sha256.iov_base, object->sha256.iov_len) != 0) + return log_error_errno(SYNTHETIC_ERRNO(EINVAL), "SHA256 sum parsed from filename and manifest don't match, refusing."); + } else { + assert(object->sha256.iov_len == SHA256_DIGEST_SIZE); + memcpy(instance->metadata.sha256sum, object->sha256.iov_base, SHA256_DIGEST_SIZE); + instance->metadata.sha256sum_set = true; + } + + if (instance->metadata.size != UINT64_MAX) { + if (instance->metadata.size != object->size) + return log_error_errno(SYNTHETIC_ERRNO(EINVAL), "Size parsed from filename and manifest don't match, refusing."); + } else + instance->metadata.size = object->size; + + if (instance->metadata.mode == MODE_INVALID) + instance->metadata.mode = object->mode; + if (instance->metadata.mtime == USEC_INFINITY && object->mtime_nsec < (NSEC_INFINITY / NSEC_PER_USEC) * NSEC_PER_USEC) + instance->metadata.mtime = object->mtime_nsec / NSEC_PER_USEC; + if (!instance->metadata.partition_uuid_set && object->gpt_uuid_set) { + instance->metadata.partition_uuid = object->gpt_uuid; + instance->metadata.partition_uuid_set = true; + } + + if (!instance->metadata.partition_flags_set && object->gpt_flags_set) { + instance->metadata.partition_flags = object->gpt_flags; + instance->metadata.partition_flags_set = true; + } + + if (instance->metadata.no_auto < 0 && object->gpt_no_auto >= 0) + instance->metadata.no_auto = object->gpt_no_auto; + if (instance->metadata.growfs < 0 && object->gpt_growfs >= 0) + instance->metadata.growfs = object->gpt_growfs; + if (instance->metadata.read_only < 0 && object->read_only >= 0) + instance->metadata.read_only = object->read_only; + + return 0; +} + +static bool uapi16_encoding_is_supported(const char *encoding) { + /* The compression algorithms systemd-pull and systemd-import can decode, by their canonical names, + * which is what we pass on to them via --compression= */ + Compression c = compression_from_string(encoding); + return c >= 0 && c != COMPRESSION_NONE && compression_supported(c); +} + +static int uapi16_object_resolve_sources(Resource *rr, Uapi16Object *o) { + size_t n = 0; + int r; + + assert(rr); + assert(o); + + /* Validates the "contents" array of a file object, i.e. the list of alternative sources the file may + * be acquired from. Resolves relative "file" references against Path= into absolute URLs, and drops + * entries we cannot make use of. Returns > 0 if the object is usable (i.e. at least one source + * remains, or none was listed to begin with, in which case the file is acquired by its name), and 0 + * if it shall be skipped. */ + + for (size_t k = 0; k < o->n_contents; k++) { + Uapi16Contents *c = o->contents + k; + bool usable = true; + + /* An entry with neither "file", "url" nor "literal" set refers to a file of the object's + * name, see the UAPI.16 specification. */ + if (!c->file && !c->url && !uapi16_contents_has_literal(c)) { + c->file = strdup(o->name); + if (!c->file) + return log_oom(); + } + + if (c->file) { + /* Refuse "%" for the same reason as in sha256sums_line() */ + if (!uapi16_name_is_valid(c->file) || strchr(c->file, '%')) { + log_warning("File '%s' refers to invalid file name '%s' in its contents, ignoring source.", o->name, c->file); + usable = false; + } else { + /* "file" references are relative to Path=, even if the manifest itself is + * acquired from elsewhere via ManifestPath=, the same way as files that list + * no "contents" at all */ + _cleanup_free_ char *url = NULL; + r = import_url_append_component(rr->path, c->file, &url); + if (r < 0) + return log_error_errno(r, "Failed to build URL for '%s': %m", c->file); + + free_and_replace(c->url, url); + c->file = mfree(c->file); + } + + } else if (c->url) { + if (!http_url_is_valid(c->url) && !file_url_is_valid(c->url)) { + log_warning("File '%s' refers to invalid URL '%s' in its contents, ignoring source.", o->name, c->url); + usable = false; + } + } + + if (usable && c->encoding && !uapi16_encoding_is_supported(c->encoding)) { + log_warning("File '%s' has a source with unsupported encoding '%s', ignoring source.", o->name, c->encoding); + usable = false; + } + + /* Whether the decoded source consists of the file and nothing else */ + bool whole = c->offset == 0 && (c->original_size == UINT64_MAX || c->original_size == o->size); + + if (usable && !whole) { + if (!c->url && c->encoding) { + /* We cannot cut into encoded inline data, and leave decoding to systemd-import */ + log_warning("File '%s' has encoded inline data that carries more than the file, ignoring source.", o->name); + usable = false; + } else if (c->url && rr->type == RESOURCE_URL_TAR) { + /* systemd-pull can only acquire a range of a source for raw images */ + log_warning("File '%s' has a source that carries more than the file, which is not supported for tarballs, ignoring source.", o->name); + usable = false; + } + } + + if (usable && c->url && !iovec_is_set(&c->encoded_sha256)) { + /* Without the digest of the downloaded bytes we cannot verify the download as a + * whole. If the source is not encoded and consists of the file and nothing else, it + * is identical to the file though, and we can use the file's digest. Otherwise we + * can still verify the file once it has been decoded and cut out of the source, as + * long as its digest is known. */ + if (!c->encoding && whole) { + if (!iovec_memdup(&o->sha256, &c->encoded_sha256)) + return log_oom(); + } else if (!iovec_is_set(&o->sha256)) { + log_warning("File '%s' has a source without SHA256 digest that does not consist of the file alone, and no SHA256 digest of its own, ignoring source.", o->name); + usable = false; + } + } + + if (!usable) { + uapi16_contents_done(c); + continue; + } + + /* Keep the array compact */ + if (n != k) { + o->contents[n] = *c; + *c = UAPI16_CONTENTS_NULL; + } + n++; + } + + if (o->n_contents > 0 && n == 0) { + log_warning("File '%s' has no usable source, skipping.", o->name); + o->n_contents = 0; + return 0; + } + + o->n_contents = n; + return 1; +} + +typedef struct Uapi16ProcessContext { + Resource *resource; + const XAttrMatchFilter *filter; + const GptPartitionType *match_partition_type; +} Uapi16ProcessContext; + +static int uapi16_process_object(Uapi16Object *object, size_t index, void *userdata) { + Uapi16ProcessContext *pc = ASSERT_PTR(userdata); + Resource *rr = ASSERT_PTR(pc->resource); + int r; + + assert(object); + + /* Honour validity properties, but otherwise ignore intro object */ + if (index == 0) { + r = uapi16_object_check_validity(object); + if (r < 0) { + if (verify_freshness()) + return log_error_errno(r, "Manifest file marked invalid: %m"); + + log_warning_errno(r, "Manifest file marked invalid, but freshness checks disabled, proceeding."); + } + + return 0; + } + + /* Ignore non-regular files */ + if (!S_ISREG(object->type)) { + log_debug("Skipping file '%s' as it is not a regular file.", object->name); + return 0; + } + + /* Ignore files whose partition type does not match the one of the partitions we'd write them to */ + if (object->gpt_type_uuid_set && pc->match_partition_type && + !sd_id128_equal(object->gpt_type_uuid, pc->match_partition_type->uuid)) { + log_debug("Skipping file '%s' as its partition type does not match the target's.", object->name); + return 0; + } + + r = uapi16_object_check_validity(object); + if (r < 0) { + if (verify_freshness()) { + log_warning_errno(r, "File '%s' marked invalid, skipping: %m", object->name); + return 0; + } + + log_warning_errno(r, "File '%s' marked invalid, but freshness checks disabled, proceeding.", object->name); + } + + if (object->size == UINT64_MAX) { + log_warning_errno(SYNTHETIC_ERRNO(EBADMSG), "File '%s' lacks a size, skipping.", object->name); + return 0; + } + + if (!iovec_is_set(&object->sha256)) { + log_warning_errno(SYNTHETIC_ERRNO(EBADMSG), "File '%s' lacks a SHA256 digest, skipping.", object->name); + return 0; + } + + /* Refuse "%" for the same reason as in sha256sums_line() */ + if (strchr(object->name, '%')) { + log_warning_errno(SYNTHETIC_ERRNO(EBADMSG), "File '%s' contains a '%%' character, skipping.", object->name); + return 0; + } + + r = uapi16_object_resolve_sources(rr, object); + if (r <= 0) + return r; + + _cleanup_(instance_metadata_destroy) InstanceMetadata extracted_fields = INSTANCE_METADATA_NULL; + r = pattern_match_many(rr->patterns, object->name, &extracted_fields); + if (r < 0) + return log_error_errno(r, "Failed to match pattern: %m"); + if (!IN_SET(r, PATTERN_MATCH_YES, PATTERN_MATCH_YES_AND_RETRY)) + return 0; + + /* Skip instances restricted to other systems via extended attributes */ + r = xattr_match_filter_test_uapi16_object(pc->filter, object); + if (r < 0) + return log_error_errno(r, "Failed to check extended attributes of '%s' listed in manifest: %m", object->name); + if (r == 0) + return 0; + + _cleanup_free_ char *path = NULL; + r = import_url_append_component(rr->path, object->name, &path); + if (r < 0) + return log_error_errno(r, "Failed to build instance URL: %m"); + + Instance *instance; + r = resource_add_instance(rr, path, &extracted_fields, &instance); + if (r < 0) + return r; + + /* Now copy the fields from the manifest into our instances logic */ + r = instance_copy_uapi16(instance, object); + if (r < 0) + return r; + + /* And take possession of the list of sources, we'll need it when acquiring the file */ + instance->sources = TAKE_PTR(object->contents); + instance->n_sources = object->n_contents; + object->n_contents = 0; + + /* Similar, for the extended attributes to apply when installing */ + instance->user_xattrs = TAKE_PTR(object->user_xattrs); + instance->n_user_xattrs = object->n_user_xattrs; + object->n_user_xattrs = 0; + + return 0; +} + +static int uapi16_process( + Resource *rr, + const struct iovec *manifest, + X509 *const *certificates, + size_t n_certificates, + const XAttrMatchFilter *filter, + const GptPartitionType *match_partition_type) { + + assert(rr); + assert(manifest); + + if (!iovec_is_set(manifest)) + return log_error_errno(SYNTHETIC_ERRNO(EBADMSG), "Manifest is empty, refusing."); + + _cleanup_fclose_ FILE *f = fmemopen_unlocked(manifest->iov_base, manifest->iov_len, "r"); + if (!f) + return log_error_errno(errno, "Failed to open manifest buffer as stream: %m"); + + return uapi16_manifest_read( + f, + certificates, + n_certificates, + uapi16_process_object, + &(Uapi16ProcessContext) { + .resource = rr, + .filter = filter, + .match_partition_type = match_partition_type, + }); +} + static int resource_load_from_web( Resource *rr, bool verify, - Hashmap **web_cache) { + X509 *const *certificates, + size_t n_certificates, + Hashmap **web_cache, + const XAttrMatchFilter *filter, + const GptPartitionType *match_partition_type) { _cleanup_(iovec_done) struct iovec buf = {}; struct iovec manifest = {}; @@ -639,7 +986,7 @@ static int resource_load_from_web( POINTER_MAY_BE_NULL(web_cache); /* Key the cache by the URL of the manifest itself, not the one of the resource, as the former - * depends on ManifestPath= too. */ + * depends on ManifestFormat= and ManifestPath= too. */ _cleanup_free_ char *manifest_url = NULL; r = resource_manifest_url(rr, &manifest_url); if (r < 0) @@ -660,7 +1007,27 @@ static int resource_load_from_web( manifest = buf; } - r = sha256sums_process(rr, &manifest); + switch (rr->manifest_format) { + + case MANIFEST_FORMAT_SHA256SUMS: + r = sha256sums_process(rr, &manifest); + break; + + case MANIFEST_FORMAT_UAPI16: + /* UAPI.16 manifests carry their signatures inline, hence systemd-pull didn't verify + * anything, and we do it here. */ + r = uapi16_process( + rr, + &manifest, + verify ? certificates : NULL, + verify ? n_certificates : 0, + filter, + match_partition_type); + break; + + default: + assert_not_reached(); + } if (r < 0) return r; @@ -696,21 +1063,32 @@ static int instance_cmp(Instance *const*a, Instance *const*b) { return path_compare((*a)->path, (*b)->path); } -int resource_load_instances(Resource *rr, bool verify, Hashmap **web_cache) { +int resource_load_instances( + Resource *rr, + bool verify, + X509 *const *certificates, + size_t n_certificates, + Hashmap **web_cache, + const XAttrMatchFilter *filter, + const GptPartitionType *match_partition_type) { int r; assert(rr); + /* If match_partition_type is specified, files listed in UAPI.16 manifests that declare a different + * partition type are skipped. This is used to filter the sources by the partition type of the + * target the files would be written to. */ + switch (rr->type) { case RESOURCE_TAR: case RESOURCE_REGULAR_FILE: - r = resource_load_from_directory(rr, S_IFREG); + r = resource_load_from_directory(rr, S_IFREG, filter); break; case RESOURCE_DIRECTORY: case RESOURCE_SUBVOLUME: - r = resource_load_from_directory(rr, S_IFDIR); + r = resource_load_from_directory(rr, S_IFDIR, filter); break; case RESOURCE_PARTITION: @@ -719,7 +1097,7 @@ int resource_load_instances(Resource *rr, bool verify, Hashmap **web_cache) { case RESOURCE_URL_FILE: case RESOURCE_URL_TAR: - r = resource_load_from_web(rr, verify, web_cache); + r = resource_load_from_web(rr, verify, certificates, n_certificates, web_cache, filter, match_partition_type); break; default: diff --git a/src/sysupdate/sysupdate-resource.h b/src/sysupdate/sysupdate-resource.h index 2465ad92c4f..0a9119d43aa 100644 --- a/src/sysupdate/sysupdate-resource.h +++ b/src/sysupdate/sysupdate-resource.h @@ -4,6 +4,7 @@ #include "gpt.h" #include "manifest-util.h" #include "sysupdate-forward.h" +#include "xattr-match.h" typedef enum ResourceType { RESOURCE_URL_FILE, @@ -79,8 +80,9 @@ typedef struct Resource { char **patterns; GptPartitionType partition_type; bool partition_type_set; - ManifestFormat manifest_format; /* Only MANIFEST_FORMAT_SHA256SUMS for now */ + ManifestFormat manifest_format; /* MANIFEST_FORMAT_SHA256SUMS or MANIFEST_FORMAT_UAPI16 */ char *manifest_path; + bool match_xattr; /* MatchXAttr= */ /* All instances of this resource we found */ Instance **instances; @@ -94,7 +96,16 @@ void resource_destroy(Resource *rr); bool resource_has_glob_directory_pattern(Resource *rr); -int resource_load_instances(Resource *rr, bool verify, Hashmap **web_cache); +unsigned resource_xattr_match_mask(const Resource *rr); + +int resource_load_instances( + Resource *rr, + bool verify, + X509 *const *certificates, + size_t n_certificates, + Hashmap **web_cache, + const XAttrMatchFilter *filter, + const GptPartitionType *match_partition_type); Instance* resource_find_instance(Resource *rr, const char *version); diff --git a/src/sysupdate/sysupdate-transfer.c b/src/sysupdate/sysupdate-transfer.c index 8d098666751..bc895a5320f 100644 --- a/src/sysupdate/sysupdate-transfer.c +++ b/src/sysupdate/sysupdate-transfer.c @@ -11,6 +11,7 @@ #include "build-path.h" #include "chase.h" #include "conf-parser.h" +#include "data-fd-util.h" #include "dirent-util.h" #include "errno-util.h" #include "event-util.h" @@ -23,6 +24,8 @@ #include "hexdecoct.h" #include "install-file.h" #include "json-util.h" +#include "iovec-util.h" +#include "memory-util.h" #include "mkdir.h" #include "notify-recv.h" #include "parse-helpers.h" @@ -32,6 +35,7 @@ #include "pidref.h" #include "process-util.h" #include "rm-rf.h" +#include "sha256.h" #include "signal-util.h" #include "specifier.h" #include "stdio-util.h" @@ -47,6 +51,7 @@ #include "sysupdate-transfer.h" #include "time-util.h" #include "web-util.h" +#include "xattr-util.h" /* Default value for InstancesMax= for fs object targets */ #define DEFAULT_FILE_INSTANCES_MAX 3 @@ -120,6 +125,9 @@ Transfer* transfer_new(Context *ctx) { .context = ctx, }; + /* Available instances are matched against the system by default, installed ones are not */ + t->source.match_xattr = true; + return t; } @@ -611,6 +619,11 @@ static int transfer_finalize(Transfer *t, const char *origin, unsigned line, Has "Target type '%s' is incompatible with source type '%s', refusing.", resource_type_to_string(t->target.type), resource_type_to_string(t->source.type)); + /* Partitions carry no extended attributes */ + if (t->target.type == RESOURCE_PARTITION && resource_xattr_match_mask(&t->target) != 0) + return log_syntax(NULL, LOG_ERR, origin, line, SYNTHETIC_ERRNO(EINVAL), + "MatchXAttr= in the [Target] section is not supported for partition targets, refusing."); + if (!t->source.path && !t->source.path_auto) return log_syntax(NULL, LOG_ERR, origin, line, SYNTHETIC_ERRNO(EINVAL), "Source specification lacks Path=."); @@ -638,7 +651,7 @@ static int transfer_finalize(Transfer *t, const char *origin, unsigned line, Has "Source path is not a valid HTTP, HTTPS, file, or provider URL: %s", t->source.path); } - if (t->source.manifest_format != MANIFEST_FORMAT_SHA256SUMS) + if (!IN_SET(t->source.manifest_format, MANIFEST_FORMAT_SHA256SUMS, MANIFEST_FORMAT_UAPI16)) return log_syntax(NULL, LOG_ERR, origin, line, SYNTHETIC_ERRNO(EINVAL), "Manifest format '%s' not supported for source specifications, refusing.", manifest_format_to_string(t->source.manifest_format)); @@ -721,11 +734,13 @@ int transfer_read_definition(Transfer *t, const char *path, const char **dirs, H { "Source", "MatchPattern", config_parse_resource_pattern, MATCH_PATTERN_SOURCE, &t->source.patterns }, { "Source", "ManifestFormat", config_parse_resource_manifest_format, 0, &t->source.manifest_format }, { "Source", "ManifestPath", config_parse_resource_manifest_path, 0, &t->source.manifest_path }, + { "Source", "MatchXAttr", config_parse_bool, 0, &t->source.match_xattr }, { "Target", "Type", config_parse_resource_type, 0, &t->target.type }, { "Target", "Path", config_parse_resource_path, 0, &t->target }, { "Target", "PathRelativeTo", config_parse_resource_path_relto, 0, &t->target.path_relative_to }, { "Target", "MatchPattern", config_parse_resource_pattern, MATCH_PATTERN_TARGET, &t->target.patterns }, { "Target", "MatchPartitionType", config_parse_resource_ptype, 0, &t->target }, + { "Target", "MatchXAttr", config_parse_bool, 0, &t->target.match_xattr }, { "Target", "PartitionUUID", config_parse_partition_uuid, 0, t }, { "Target", "PartitionFlags", config_parse_partition_flags, 0, t }, { "Target", "PartitionNoAuto", config_parse_tristate, 0, &t->no_auto }, @@ -930,16 +945,29 @@ static int dispatch_current_symlink(const char *name, sd_json_variant *variant, return free_and_replace(*current_symlink, p); } +static int dispatch_source_match(const char *name, sd_json_variant *variant, sd_json_dispatch_flags_t flags, void *userdata) { + bool *b = ASSERT_PTR(userdata); + + /* MatchXAttr= defaults to on in the [Source] section, hence map null to true */ + if (sd_json_variant_is_null(variant)) { + *b = true; + return 0; + } + + return sd_json_dispatch_stdbool(name, variant, flags, userdata); +} + static int dispatch_transfer_source(const char *name, sd_json_variant *variant, sd_json_dispatch_flags_t flags, void *userdata) { Transfer *t = ASSERT_PTR(userdata); static const sd_json_dispatch_field dispatch_table[] = { - { "type", SD_JSON_VARIANT_STRING, dispatch_resource_type, offsetof(Transfer, source.type), SD_JSON_MANDATORY }, - { "path", SD_JSON_VARIANT_STRING, dispatch_resource_path, offsetof(Transfer, source), SD_JSON_MANDATORY }, - { "pathRelativeTo", SD_JSON_VARIANT_STRING, dispatch_path_relative_to, offsetof(Transfer, source.path_relative_to), SD_JSON_NULLABLE }, - { "matchPattern", SD_JSON_VARIANT_ARRAY, dispatch_source_patterns, offsetof(Transfer, source), SD_JSON_MANDATORY }, - { "manifestFormat", SD_JSON_VARIANT_STRING, dispatch_manifest_format, offsetof(Transfer, source.manifest_format), SD_JSON_NULLABLE }, - { "manifestPath", SD_JSON_VARIANT_STRING, sd_json_dispatch_string, offsetof(Transfer, source.manifest_path), SD_JSON_NULLABLE }, + { "type", SD_JSON_VARIANT_STRING, dispatch_resource_type, offsetof(Transfer, source.type), SD_JSON_MANDATORY }, + { "path", SD_JSON_VARIANT_STRING, dispatch_resource_path, offsetof(Transfer, source), SD_JSON_MANDATORY }, + { "pathRelativeTo", SD_JSON_VARIANT_STRING, dispatch_path_relative_to, offsetof(Transfer, source.path_relative_to), SD_JSON_NULLABLE }, + { "matchPattern", SD_JSON_VARIANT_ARRAY, dispatch_source_patterns, offsetof(Transfer, source), SD_JSON_MANDATORY }, + { "manifestFormat", SD_JSON_VARIANT_STRING, dispatch_manifest_format, offsetof(Transfer, source.manifest_format), SD_JSON_NULLABLE }, + { "manifestPath", SD_JSON_VARIANT_STRING, sd_json_dispatch_string, offsetof(Transfer, source.manifest_path), SD_JSON_NULLABLE }, + { "matchXAttr", SD_JSON_VARIANT_BOOLEAN, dispatch_source_match, offsetof(Transfer, source.match_xattr), SD_JSON_NULLABLE }, {}, }; @@ -969,6 +997,7 @@ static int dispatch_transfer_target(const char *name, sd_json_variant *variant, { "instancesMax", _SD_JSON_VARIANT_TYPE_INVALID, dispatch_instances_max, offsetof(Transfer, instances_max), 0 }, { "removeTemporary", SD_JSON_VARIANT_BOOLEAN, sd_json_dispatch_tristate, offsetof(Transfer, remove_temporary), SD_JSON_NULLABLE }, { "currentSymlink", SD_JSON_VARIANT_STRING, dispatch_current_symlink, offsetof(Transfer, current_symlink), SD_JSON_NULLABLE }, + { "matchXAttr", SD_JSON_VARIANT_BOOLEAN, sd_json_dispatch_stdbool, offsetof(Transfer, target.match_xattr), SD_JSON_NULLABLE }, {}, }; @@ -989,17 +1018,17 @@ int transfer_from_json(Transfer *t, sd_json_variant *v, const char *origin, Hash * from, for logging purposes. */ static const sd_json_dispatch_field dispatch_table[] = { - { "id", SD_JSON_VARIANT_STRING, json_dispatch_filename, offsetof(Transfer, id), SD_JSON_MANDATORY|SD_JSON_STRICT }, - { "minVersion", SD_JSON_VARIANT_STRING, json_dispatch_version, offsetof(Transfer, min_version), SD_JSON_NULLABLE }, - { "maxVersion", SD_JSON_VARIANT_STRING, json_dispatch_version, offsetof(Transfer, max_version), SD_JSON_NULLABLE }, - { "protectVersion", SD_JSON_VARIANT_ARRAY, json_dispatch_versions, offsetof(Transfer, protected_versions), SD_JSON_NULLABLE }, - { "verify", SD_JSON_VARIANT_BOOLEAN, sd_json_dispatch_tristate, offsetof(Transfer, verify), SD_JSON_NULLABLE }, - { "changeLog", SD_JSON_VARIANT_ARRAY, json_dispatch_http_urls, offsetof(Transfer, changelog), SD_JSON_NULLABLE }, - { "appStream", SD_JSON_VARIANT_ARRAY, json_dispatch_http_urls, offsetof(Transfer, appstream), SD_JSON_NULLABLE }, - { "features", SD_JSON_VARIANT_ARRAY, sd_json_dispatch_strv, offsetof(Transfer, features), SD_JSON_NULLABLE }, - { "requisiteFeatures", SD_JSON_VARIANT_ARRAY, sd_json_dispatch_strv, offsetof(Transfer, requisite_features), SD_JSON_NULLABLE }, - { "source", SD_JSON_VARIANT_OBJECT, dispatch_transfer_source, 0, SD_JSON_MANDATORY }, - { "target", SD_JSON_VARIANT_OBJECT, dispatch_transfer_target, 0, SD_JSON_MANDATORY }, + { "id", SD_JSON_VARIANT_STRING, json_dispatch_filename, offsetof(Transfer, id), SD_JSON_MANDATORY|SD_JSON_STRICT }, + { "minVersion", SD_JSON_VARIANT_STRING, json_dispatch_version, offsetof(Transfer, min_version), SD_JSON_NULLABLE }, + { "maxVersion", SD_JSON_VARIANT_STRING, json_dispatch_version, offsetof(Transfer, max_version), SD_JSON_NULLABLE }, + { "protectVersion", SD_JSON_VARIANT_ARRAY, json_dispatch_versions, offsetof(Transfer, protected_versions), SD_JSON_NULLABLE }, + { "verify", SD_JSON_VARIANT_BOOLEAN, sd_json_dispatch_tristate, offsetof(Transfer, verify), SD_JSON_NULLABLE }, + { "changeLog", SD_JSON_VARIANT_ARRAY, json_dispatch_http_urls, offsetof(Transfer, changelog), SD_JSON_NULLABLE }, + { "appStream", SD_JSON_VARIANT_ARRAY, json_dispatch_http_urls, offsetof(Transfer, appstream), SD_JSON_NULLABLE }, + { "features", SD_JSON_VARIANT_ARRAY, sd_json_dispatch_strv, offsetof(Transfer, features), SD_JSON_NULLABLE }, + { "requisiteFeatures", SD_JSON_VARIANT_ARRAY, sd_json_dispatch_strv, offsetof(Transfer, requisite_features), SD_JSON_NULLABLE }, + { "source", SD_JSON_VARIANT_OBJECT, dispatch_transfer_source, 0, SD_JSON_MANDATORY }, + { "target", SD_JSON_VARIANT_OBJECT, dispatch_transfer_target, 0, SD_JSON_MANDATORY }, {}, }; @@ -1468,6 +1497,7 @@ static int helper_on_notify(sd_event_source *s, int fd, uint32_t revents, void * static int run_callout( const char *name, char *cmdline[], + int stdin_fd, const Transfer *transfer, const Instance *instance, TransferProgress callback, @@ -1507,7 +1537,13 @@ static int run_callout( if (r < 0) return log_error_errno(r, "Failed to prepare notify socket: %m"); - r = pidref_safe_fork(ctx->name, FORK_RESET_SIGNALS|FORK_DEATHSIG_SIGTERM|FORK_LOG, &ctx->pid); + /* Optionally connect the helper's stdin to the specified fd, otherwise it inherits ours */ + r = pidref_safe_fork_full( + ctx->name, + stdin_fd >= 0 ? (int[]) { stdin_fd, STDOUT_FILENO, STDERR_FILENO } : NULL, + /* except_fds= */ NULL, /* n_except_fds= */ 0, + FORK_RESET_SIGNALS|FORK_DEATHSIG_SIGTERM|FORK_LOG|(stdin_fd >= 0 ? FORK_REARRANGE_STDIO : 0), + &ctx->pid); if (r < 0) return log_error_errno(r, "Failed to fork process %s: %m", ctx->name); if (r == 0) { @@ -1633,8 +1669,247 @@ static bool transfer_zero_fill(const Transfer *t) { return partition_designator_is_verity_sig(t->target.partition_type.designator); } -int transfer_acquire_instance(Transfer *t, Instance *i, InstanceMetadata *f, TransferProgress cb, void *userdata) { +static int transfer_acquire_source( + Transfer *t, + Instance *i, + const Uapi16Contents *s, + const char *compression, + TransferProgress cb, + void *userdata) { + + _cleanup_strv_free_ char **cmdline = NULL; _cleanup_free_ char *digest = NULL; + _cleanup_close_ int literal_fd = -EBADF; + const char *from, *to; + int r; + + assert(t); + assert(i); + assert(s); + assert(compression); + assert(RESOURCE_IS_URL(i->resource->type)); + + /* Acquires a URL backed resource instance from one of its sources: either downloads it from the URL, + * or takes the literal data the manifest carries. We run everything through systemd-pull or + * systemd-import, so that decompression, sparse file handling and sandboxing are the same regardless + * of where the data comes from. The compression algorithm is passed down explicitly, so that the + * tools strictly decode with what the manifest declares, instead of guessing from the data. */ + + bool tar = i->resource->type == RESOURCE_URL_TAR; + + /* The file may be only a part of the decoded source, i.e. the range [offset, offset+size) of it. If + * the size of the source is not known it is assumed to consist of the file only. */ + bool range = s->offset != 0 || + (s->original_size != UINT64_MAX && i->metadata.size != UINT64_MAX && s->original_size != i->metadata.size); + + if (s->url) { + from = s->url; + r = strv_extend_many( + &cmdline, + SYSTEMD_PULL_PATH, + tar ? "tar" : "raw", + "--direct"); /* just download the specified URL, don't download anything else */ + if (r < 0) + return log_oom(); + + /* If the file is cut out of a source that is not encoded, verifying the file's own digest + * (see below) is sufficient, and allows systemd-pull to acquire only the range of the source + * that makes up the file, via an HTTP range request, instead of the whole source. */ + if (iovec_is_set(&s->encoded_sha256) && !(range && !s->encoding && i->metadata.sha256sum_set)) { + digest = hexmem(s->encoded_sha256.iov_base, s->encoded_sha256.iov_len); + if (!digest) + return log_oom(); + + r = strv_extend_many(&cmdline, "--verify", digest); /* validate by explicit SHA256 sum */ + } else { + /* Without the digest of the download as a whole, we require the file's digest to be + * known, so that we can validate the decoded file instead, see below. */ + if (!i->metadata.sha256sum_set) + return log_error_errno(SYNTHETIC_ERRNO(EINVAL), "SHA256 checksum not known for download '%s', refusing.", s->url); + + r = strv_extend_many(&cmdline, "--verify", "no"); + } + if (r < 0) + return log_oom(); + + /* systemd-pull supports ranges for raw images only. Other sources have been dropped when + * the manifest was processed already. */ + assert(!range || !tar); + + if (s->offset != 0) { + r = strv_extendf(&cmdline, "--source-offset=%" PRIu64, s->offset); + if (r < 0) + return log_oom(); + } + + if (range && i->metadata.size != UINT64_MAX) { + r = strv_extendf(&cmdline, "--source-size=%" PRIu64, i->metadata.size); + if (r < 0) + return log_oom(); + } + } else { + struct iovec data = s->literal; + + /* All other sources have been turned into URLs when the manifest was processed. Note that + * the inline data may be empty, for empty files. */ + assert(uapi16_contents_has_literal(s)); + + /* Literal data: check it against its digest right here, cut out the part that makes up the + * file, and pass it to systemd-import via a memory file descriptor as standard input. */ + + if (iovec_is_set(&s->encoded_sha256) && + memcmp_nn(SHA256_DIRECT(data.iov_base, data.iov_len), SHA256_DIGEST_SIZE, + s->encoded_sha256.iov_base, s->encoded_sha256.iov_len) != 0) + return log_error_errno(SYNTHETIC_ERRNO(EBADMSG), "Inline data of '%s' does not match its SHA256 digest, refusing.", i->path); + + /* Encoded data we cannot cut into, and leave decoding to the importer. Encoded sources that + * carry more than the file have been dropped when the manifest was processed already. */ + assert(!range || !s->encoding); + + if (!s->encoding) { + if (s->offset > data.iov_len) + return log_error_errno(SYNTHETIC_ERRNO(EBADMSG), "Inline data of '%s' is shorter than the offset of the file in it, refusing.", i->path); + + data = IOVEC_MAKE((uint8_t*) data.iov_base + s->offset, data.iov_len - s->offset); + + if (i->metadata.size != UINT64_MAX) { + if (data.iov_len < i->metadata.size) + return log_error_errno(SYNTHETIC_ERRNO(EBADMSG), "Inline data of '%s' is shorter than the file, refusing.", i->path); + + data.iov_len = i->metadata.size; + } + + if (i->metadata.sha256sum_set && + memcmp_nn(SHA256_DIRECT(data.iov_base, data.iov_len), SHA256_DIGEST_SIZE, + i->metadata.sha256sum, sizeof(i->metadata.sha256sum)) != 0) + return log_error_errno(SYNTHETIC_ERRNO(EBADMSG), "Inline data of '%s' does not match the file's SHA256 digest, refusing.", i->path); + } + + /* Place the inline data in a sealed memory file, which we hand to systemd-import as stdin */ + literal_fd = data_fd_from_iovec("sysupdate-literal", &data); + if (literal_fd < 0) + return log_error_errno(literal_fd, "Failed to allocate memory file for inline data: %m"); + + from = "-"; /* i.e. read from stdin */ + r = strv_extend_many( + &cmdline, + SYSTEMD_IMPORT_PATH, + tar ? "tar" : "raw", + "--direct"); /* just copy/unpack the specified file, don't do anything else */ + } + if (r < 0) + return log_oom(); + + r = strv_extendf(&cmdline, "--compression=%s", compression); + if (r < 0) + return log_oom(); + + if (i->metadata.sha256sum_set && (s->encoding || (s->url && (range || !iovec_is_set(&s->encoded_sha256))))) { + /* The digests checked so far cover the encoded data or the whole source only (if any), hence + * let the tool also check the decoded data cut down to the file against the file's digest */ + _cleanup_free_ char *decoded_digest = hexmem(i->metadata.sha256sum, sizeof(i->metadata.sha256sum)); + if (!decoded_digest) + return log_oom(); + + r = strv_extendf(&cmdline, "--verify-decoded=%s", decoded_digest); + if (r < 0) + return log_oom(); + } + + r = strv_extend(&cmdline, t->context->sync ? "--sync=yes" : "--sync=no"); + if (r < 0) + return log_oom(); + + if (tar) { + assert(IN_SET(t->target.type, RESOURCE_DIRECTORY, RESOURCE_SUBVOLUME)); + + r = strv_extend(&cmdline, t->target.type == RESOURCE_SUBVOLUME ? "--btrfs-subvol=yes" : "--btrfs-subvol=no"); + if (r < 0) + return log_oom(); + + to = t->temporary_partial_path; + + } else if (t->target.type == RESOURCE_PARTITION) { + r = strv_extendf(&cmdline, "--offset=%" PRIu64, t->partition_info.start); + if (r < 0) + return log_oom(); + + r = strv_extendf(&cmdline, "--size-max=%" PRIu64, t->partition_info.size); + if (r < 0) + return log_oom(); + + r = strv_extend(&cmdline, transfer_zero_fill(t) ? "--zero-fill=yes" : "--zero-fill=no"); + if (r < 0) + return log_oom(); + + to = t->target.path; + + } else { + assert(t->target.type == RESOURCE_REGULAR_FILE); + to = t->temporary_partial_path; + } + + r = strv_extend_many(&cmdline, from, to); + if (r < 0) + return log_oom(); + + return run_callout( + s->url ? (tar ? "(sd-pull-tar)" : "(sd-pull-raw)") : (tar ? "(sd-import-tar)" : "(sd-import-raw)"), + cmdline, + literal_fd, + t, i, cb, userdata); +} + +static int instance_apply_user_xattrs(Instance *i, const char *path) { + int r; + + assert(i); + assert(path); + + /* Applies the user extended attributes listed in the manifest to the installed inode, so that they + * are retained on the target. This in particular matters for the ones MatchXAttr= looks at. Failing + * to do so is not fatal, the instance is fully usable without them. Returns > 0 if any attribute + * was applied. */ + + if (i->n_user_xattrs == 0) + return 0; + + _cleanup_close_ int fd = xopenat(AT_FDCWD, path, O_PATH|O_NOFOLLOW); + if (fd < 0) { + log_warning_errno(fd, "Failed to open '%s' to apply extended attributes, ignoring: %m", path); + return 0; + } + + bool applied = false; + FOREACH_ARRAY(x, i->user_xattrs, i->n_user_xattrs) { + _cleanup_free_ char *n = strjoin("user.", x->name); + if (!n) + return log_oom(); + + r = xsetxattr_full( + fd, + /* path= */ NULL, + AT_EMPTY_PATH, + n, + x->value.iov_base, + x->value.iov_len, + /* xattr_flags= */ 0); + if (ERRNO_IS_NEG_NOT_SUPPORTED(r)) { + log_notice_errno(r, "File system backing '%s' does not support extended attributes, not applying them: %m", path); + break; + } + if (r < 0) { + log_warning_errno(r, "Failed to set extended attribute '%s' on '%s', ignoring: %m", n, path); + continue; + } + + applied = true; + } + + return applied; +} + +int transfer_acquire_instance(Transfer *t, Instance *i, InstanceMetadata *f, TransferProgress cb, void *userdata) { char offset[DECIMAL_STR_MAX(uint64_t)+1], max_size[DECIMAL_STR_MAX(uint64_t)+1]; const char *where = NULL; Instance *existing; @@ -1709,18 +1984,6 @@ int transfer_acquire_instance(Transfer *t, Instance *i, InstanceMetadata *f, Tra log_info("%s Acquiring %s %s %s...", glyph(GLYPH_DOWNLOAD), i->path, glyph(GLYPH_ARROW_RIGHT), where); - if (RESOURCE_IS_URL(i->resource->type)) { - /* For URL sources we require the SHA256 sum to be known so that we can validate the - * download. */ - - if (!i->metadata.sha256sum_set) - return log_error_errno(SYNTHETIC_ERRNO(EINVAL), "SHA256 checksum not known for download '%s', refusing.", i->path); - - digest = hexmem(i->metadata.sha256sum, sizeof(i->metadata.sha256sum)); - if (!digest) - return log_oom(); - } - switch (i->resource->type) { /* Source */ case RESOURCE_REGULAR_FILE: @@ -1742,7 +2005,7 @@ int transfer_acquire_instance(Transfer *t, Instance *i, InstanceMetadata *f, Tra t->context->sync ? "--sync=yes" : "--sync=no", i->path, t->temporary_partial_path), - t, i, cb, userdata); + /* stdin_fd= */ -EBADF, t, i, cb, userdata); break; case RESOURCE_PARTITION: @@ -1760,7 +2023,7 @@ int transfer_acquire_instance(Transfer *t, Instance *i, InstanceMetadata *f, Tra t->context->sync ? "--sync=yes" : "--sync=no", i->path, t->target.path), - t, i, cb, userdata); + /* stdin_fd= */ -EBADF, t, i, cb, userdata); break; default: @@ -1784,7 +2047,7 @@ int transfer_acquire_instance(Transfer *t, Instance *i, InstanceMetadata *f, Tra t->target.type == RESOURCE_SUBVOLUME ? "--btrfs-subvol=yes" : "--btrfs-subvol=no", i->path, t->temporary_partial_path), - t, i, cb, userdata); + /* stdin_fd= */ -EBADF, t, i, cb, userdata); break; case RESOURCE_TAR: @@ -1801,68 +2064,54 @@ int transfer_acquire_instance(Transfer *t, Instance *i, InstanceMetadata *f, Tra t->target.type == RESOURCE_SUBVOLUME ? "--btrfs-subvol=yes" : "--btrfs-subvol=no", i->path, t->temporary_partial_path), - t, i, cb, userdata); + /* stdin_fd= */ -EBADF, t, i, cb, userdata); break; case RESOURCE_URL_FILE: - - switch (t->target.type) { - - case RESOURCE_REGULAR_FILE: - - /* url file → regular file */ - - r = run_callout("(sd-pull-raw)", - STRV_MAKE( - SYSTEMD_PULL_PATH, - "raw", - "--direct", /* just download the specified URL, don't download anything else */ - "--verify", digest, /* validate by explicit SHA256 sum */ - t->context->sync ? "--sync=yes" : "--sync=no", - i->path, - t->temporary_partial_path), - t, i, cb, userdata); - break; - - case RESOURCE_PARTITION: - - /* url file → partition */ - - r = run_callout("(sd-pull-raw)", - STRV_MAKE( - SYSTEMD_PULL_PATH, - "raw", - "--direct", /* just download the specified URL, don't download anything else */ - "--verify", digest, /* validate by explicit SHA256 sum */ - "--offset", offset, - "--size-max", max_size, - transfer_zero_fill(t) ? "--zero-fill=yes" : "--zero-fill=no", - t->context->sync ? "--sync=yes" : "--sync=no", - i->path, - t->target.path), - t, i, cb, userdata); - break; - - default: - assert_not_reached(); - } - - break; - case RESOURCE_URL_TAR: - assert(IN_SET(t->target.type, RESOURCE_DIRECTORY, RESOURCE_SUBVOLUME)); - r = run_callout("(sd-pull-tar)", - STRV_MAKE( - SYSTEMD_PULL_PATH, - "tar", - "--direct", /* just download the specified URL, don't download anything else */ - "--verify", digest, /* validate by explicit SHA256 sum */ - t->target.type == RESOURCE_SUBVOLUME ? "--btrfs-subvol=yes" : "--btrfs-subvol=no", - t->context->sync ? "--sync=yes" : "--sync=no", - i->path, - t->temporary_partial_path), - t, i, cb, userdata); + if (i->n_sources == 0) { + /* The manifest lists no sources for the file, hence acquire it by its name relative + * to the source URL. */ + Uapi16Contents fallback = UAPI16_CONTENTS_NULL; + + fallback.url = i->path; + fallback.offset = 0; + if (i->metadata.sha256sum_set) + fallback.encoded_sha256 = IOVEC_MAKE(i->metadata.sha256sum, sizeof(i->metadata.sha256sum)); + + /* For SHA256SUMS manifests we know nothing about the compression, hence let the tool + * figure it out, and decompress implicitly. UAPI.16 manifests declare encodings + * explicitly, via "contents", hence a file without it is to be taken as is: its + * digest is the one of the file as stored. */ + r = transfer_acquire_source( + t, i, &fallback, + i->resource->manifest_format == MANIFEST_FORMAT_UAPI16 ? "none" : "auto", + cb, userdata); + } else + /* The manifest lists one or more alternative sources for the file, try them in order + * until one works out. The manifest declares the encoding of each source, hence + * insist on it (or on none) and don't let the tools guess. */ + FOREACH_ARRAY(s, i->sources, i->n_sources) { + r = transfer_acquire_source(t, i, s, s->encoding ?: "none", cb, userdata); + if (r >= 0 || r == -ECANCELED) + break; + + if (s + 1 >= i->sources + i->n_sources) + break; + + log_warning_errno(r, "Failed to acquire '%s', trying alternative source: %m", i->path); + + /* In --direct mode the tools leave whatever they wrote in place when they + * fail, hence remove the partial, unverified leftovers before trying the next + * source, so that they are not combined with what that one provides. */ + if (RESOURCE_IS_FILESYSTEM(t->target.type)) { + int k = rm_rf(t->temporary_partial_path, REMOVE_ROOT|REMOVE_PHYSICAL|REMOVE_SUBVOLUME|REMOVE_MISSING_OK|REMOVE_CHMOD); + if (k < 0) + return log_error_errno(k, "Failed to remove partially acquired '%s': %m", t->temporary_partial_path); + } + } + break; default: @@ -1899,6 +2148,12 @@ int transfer_acquire_instance(Transfer *t, Instance *i, InstanceMetadata *f, Tra need_sync = true; } + r = instance_apply_user_xattrs(i, t->temporary_partial_path); + if (r < 0) + return r; + if (r > 0) + need_sync = true; + /* Synchronize */ if (t->context->sync && need_sync) { if (t->target.type == RESOURCE_REGULAR_FILE) diff --git a/src/sysupdate/sysupdate.c b/src/sysupdate/sysupdate.c index dc0844aece2..2d716dcf439 100644 --- a/src/sysupdate/sysupdate.c +++ b/src/sysupdate/sysupdate.c @@ -1,5 +1,6 @@ /* SPDX-License-Identifier: LGPL-2.1-or-later */ +#include #include #include "sd-bus.h" @@ -19,6 +20,7 @@ #include "errno-util.h" #include "fd-util.h" #include "format-table.h" +#include "fs-util.h" #include "glyph-util.h" #include "hashmap.h" #include "hexdecoct.h" @@ -50,9 +52,11 @@ #include "sysupdate-transfer.h" #include "sysupdate-update-set.h" #include "sysupdate-util.h" +#include "uapi16-manifest.h" #include "varlink-io.systemd.SysUpdate.h" #include "varlink-util.h" #include "verbs.h" +#include "xattr-match.h" static char *arg_definitions = NULL; static bool arg_sync = true; @@ -128,6 +132,12 @@ void context_done(Context *c) { c->web_cache = hashmap_free(c->web_cache); + host_properties_done(&c->host_properties); + + uapi16_certificates_free(c->manifest_certificates, c->n_manifest_certificates); + c->manifest_certificates = NULL; + c->n_manifest_certificates = 0; + c->installdb_fd = safe_close(c->installdb_fd); c->definitions = mfree(c->definitions); @@ -572,7 +582,15 @@ static int context_load_installed_instances(Context *c) { r = resource_load_instances( &t->target, c->verify >= 0 ? c->verify : t->verify != 0, - &c->web_cache); + /* certificates= */ NULL, + /* n_certificates= */ 0, + &c->web_cache, + &(const XAttrMatchFilter) { + .mask = resource_xattr_match_mask(&t->target), + .host = &c->host_properties, + .root = c->root, + }, + /* match_partition_type= */ NULL); if (r < 0) return r; } @@ -583,7 +601,15 @@ static int context_load_installed_instances(Context *c) { r = resource_load_instances( &t->target, c->verify >= 0 ? c->verify : t->verify != 0, - &c->web_cache); + /* certificates= */ NULL, + /* n_certificates= */ 0, + &c->web_cache, + &(const XAttrMatchFilter) { + .mask = resource_xattr_match_mask(&t->target), + .host = &c->host_properties, + .root = c->root, + }, + /* match_partition_type= */ NULL); if (r < 0) return r; } @@ -591,6 +617,35 @@ static int context_load_installed_instances(Context *c) { return 0; } +static int context_load_manifest_certificates(Context *c) { + int r; + + assert(c); + + /* Loads the certificates UAPI.16 manifests may be signed with, from the VOA hierarchy */ + + if (c->manifest_certificates_loaded) + return 0; + + _cleanup_close_ int root_fd = -EBADF; + if (c->root) { + root_fd = xopenat(AT_FDCWD, c->root, O_DIRECTORY|O_PATH); + if (root_fd < 0) + return log_error_errno(root_fd, "Failed to open root directory '%s': %m", c->root); + } + + r = uapi16_load_voa_certificates( + root_fd >= 0 ? root_fd : XAT_FDROOT, + SYSUPDATE_VOA_CONTEXT, + &c->manifest_certificates, + &c->n_manifest_certificates); + if (r < 0) + return r; + + c->manifest_certificates_loaded = true; + return 0; +} + static int context_load_available_instances(Context *c) { int r; @@ -600,11 +655,33 @@ static int context_load_available_instances(Context *c) { FOREACH_ARRAY(tr, c->transfers, c->n_transfers) { Transfer *t = *tr; + bool verify = c->verify >= 0 ? c->verify : t->verify != 0; + + /* UAPI.16 manifests carry their signatures inline, which we verify ourselves, against the + * certificates from the VOA hierarchy */ + if (verify && RESOURCE_IS_URL(t->source.type) && t->source.manifest_format == MANIFEST_FORMAT_UAPI16) { + r = context_load_manifest_certificates(c); + if (r < 0) + return r; + + if (c->n_manifest_certificates == 0) + return log_error_errno(SYNTHETIC_ERRNO(ENOKEY), + "Transfer '%s' requires verification of the UAPI.16 manifest signature, but no certificates found in the VOA hierarchy (role '%s', context '%s'), refusing.", + t->id, UAPI16_VOA_ROLE, SYSUPDATE_VOA_CONTEXT); + } r = resource_load_instances( &t->source, - c->verify >= 0 ? c->verify : t->verify != 0, - &c->web_cache); + verify, + c->manifest_certificates, + c->n_manifest_certificates, + &c->web_cache, + &(const XAttrMatchFilter) { + .mask = resource_xattr_match_mask(&t->source), + .host = &c->host_properties, + .root = c->root, + }, + t->target.partition_type_set ? &t->target.partition_type : NULL); if (r < 0) return r; } diff --git a/src/sysupdate/sysupdate.h b/src/sysupdate/sysupdate.h index 8824c38947a..b1cee66533a 100644 --- a/src/sysupdate/sysupdate.h +++ b/src/sysupdate/sysupdate.h @@ -1,10 +1,14 @@ /* SPDX-License-Identifier: LGPL-2.1-or-later */ #pragma once +#include "host-properties.h" #include "sysupdate-component.h" #include "sysupdate-forward.h" #include "sysupdate-target.h" +/* The VOA context (see voa-util.h) UAPI.16 manifest certificates are looked up in */ +#define SYSUPDATE_VOA_CONTEXT "sysupdate" + typedef enum SelectMode { SELECT_EXPLICIT, /* Only explicitly specified features/components */ SELECT_ALL, /* All available features/components */ @@ -55,6 +59,12 @@ typedef struct Context { Hashmap *web_cache; /* Cache for downloaded resources, keyed by URL */ + HostProperties host_properties; /* Loaded lazily, for MatchXAttr= */ + + X509 **manifest_certificates; /* Loaded lazily from the VOA hierarchy, for verifying UAPI.16 manifest signatures */ + size_t n_manifest_certificates; + bool manifest_certificates_loaded; + int installdb_fd; TargetIdentifier target_identifier; diff --git a/src/test/test-data-fd-util.c b/src/test/test-data-fd-util.c index 38ed7578949..bb920315695 100644 --- a/src/test/test-data-fd-util.c +++ b/src/test/test-data-fd-util.c @@ -11,6 +11,7 @@ #include "fileio.h" #include "fs-util.h" #include "io-util.h" +#include "iovec-util.h" #include "memfd-util.h" #include "pidref.h" #include "process-util.h" @@ -59,6 +60,10 @@ TEST(copy_data_fd) { fd2 = ASSERT_OK(copy_data_fd(fd1)); + /* Small data ends up in a sealed memfd, which we return read-only, like the temporary files */ + ASSERT_OK_POSITIVE(memfd_get_sealed(fd2)); + ASSERT_EQ(ASSERT_OK_ERRNO(fcntl(fd2, F_GETFL)) & O_ACCMODE_STRICT, O_RDONLY); + safe_close(fd1); fd1 = ASSERT_OK(memfd_new_and_seal_string("data", "hallo")); @@ -216,4 +221,49 @@ TEST(open_seekable_data_fd) { ASSERT_ERROR(open_seekable_data_fd(dir_fd, "missing", UINT64_MAX), ENOENT); } +TEST(data_fd_from_iovec) { + _cleanup_close_ int fd = -EBADF; + _cleanup_free_ uint8_t *large = NULL; + struct stat st; + char buf[16]; + + fd = ASSERT_OK(data_fd_from_iovec("data", &CONST_IOVEC_MAKE_STRING("hallo"))); + assert_fd_contents(fd, "hallo"); + + /* The fd is sealed, read-only and positioned at the beginning, so that it can be passed as stdin */ + ASSERT_OK_POSITIVE(memfd_get_sealed(fd)); + ASSERT_EQ(ASSERT_OK_ERRNO(fcntl(fd, F_GETFL)) & O_ACCMODE_STRICT, O_RDONLY); + ASSERT_OK_ZERO_ERRNO(lseek(fd, 0, SEEK_CUR)); + ASSERT_ERROR_ERRNO(write(fd, "x", 1), EBADF); + ASSERT_OK_EQ_ERRNO(read(fd, buf, sizeof(buf)), 5); + ASSERT_EQ(memcmp(buf, "hallo", 5), 0); + ASSERT_OK_ZERO_ERRNO(read(fd, buf, sizeof(buf))); + fd = safe_close(fd); + + /* Empty data, with or without a buffer, and no name */ + fd = ASSERT_OK(data_fd_from_iovec(/* name= */ NULL, &(const struct iovec) {})); + assert_fd_contents(fd, ""); + ASSERT_OK_POSITIVE(memfd_get_sealed(fd)); + fd = safe_close(fd); + + fd = ASSERT_OK(data_fd_from_iovec("data", &IOVEC_MAKE(buf, 0))); + assert_fd_contents(fd, ""); + fd = safe_close(fd); + + /* Data larger than what copy_data_fd() places in a memfd still ends up in one */ + size_t n = 1536 * 1024; + large = ASSERT_NOT_NULL(malloc(n)); + for (size_t i = 0; i < n; i++) + large[i] = (uint8_t) (i * 7); + + fd = ASSERT_OK(data_fd_from_iovec("large", &IOVEC_MAKE(large, n))); + ASSERT_OK_POSITIVE(memfd_get_sealed(fd)); + ASSERT_OK_ERRNO(fstat(fd, &st)); + ASSERT_EQ((uint64_t) st.st_size, (uint64_t) n); + + _cleanup_free_ uint8_t *copy = ASSERT_NOT_NULL(malloc(n)); + ASSERT_OK_EQ_ERRNO(pread(fd, copy, n, 0), (ssize_t) n); + ASSERT_EQ(memcmp(large, copy, n), 0); +} + DEFINE_TEST_MAIN(LOG_DEBUG); diff --git a/test/units/TEST-72-SYSUPDATE.sh b/test/units/TEST-72-SYSUPDATE.sh index 3bbab09e12e..2926d07ffbe 100755 --- a/test/units/TEST-72-SYSUPDATE.sh +++ b/test/units/TEST-72-SYSUPDATE.sh @@ -1022,9 +1022,9 @@ cmp "$MP/payload/mp-v1.bin" "$MP/target/mp-v1.bin" # there is none sed 's|^ManifestPath=.*|&\nManifestPath=|' "$MP/definitions/01-mp.transfer" >"$MP/bad-definitions/01-mp.transfer" (! "$SYSUPDATE" --definitions="$MP/bad-definitions" --verify=no list) -# Only SHA256SUMS manifests are supported for now -sed 's|^ManifestFormat=.*|ManifestFormat=uapi16|' "$MP/definitions/01-mp.transfer" >"$MP/bad-definitions/01-mp.transfer" -(! "$SYSUPDATE" --definitions="$MP/bad-definitions" --verify=no list) |& grep -F "Manifest format 'uapi16' not supported" >/dev/null +# Only SHA256SUMS and UAPI.16 manifests are supported +sed 's|^ManifestFormat=.*|ManifestFormat=mtree|' "$MP/definitions/01-mp.transfer" >"$MP/bad-definitions/01-mp.transfer" +(! "$SYSUPDATE" --definitions="$MP/bad-definitions" --verify=no list) |& grep -F "Manifest format 'mtree' not supported" >/dev/null # ManifestPath= must be a URL, and is only supported for URL sources sed 's|^ManifestPath=.*|ManifestPath=/foo/SHA256SUMS|' "$MP/definitions/01-mp.transfer" >"$MP/bad-definitions/01-mp.transfer" (! "$SYSUPDATE" --definitions="$MP/bad-definitions" --verify=no list) |& grep -F "Manifest path is not a valid" >/dev/null @@ -1032,6 +1032,326 @@ sed -e 's|^Type=url-file|Type=regular-file|' -e "s|^Path=file://|Path=|" "$MP/de (! "$SYSUPDATE" --definitions="$MP/bad-definitions" --verify=no list) |& grep -F "ManifestPath= is only supported for URL sources" >/dev/null rm -rf "$MP" +# Check that the sources listed in the "contents" arrays of a UAPI.16 manifest are honoured: "file" +# entries are fetched relative to Path= (not relative to ManifestPath=), "url" entries from the specified +# URL, "literal" and "url" entries may carry the file embedded in more data (at "offset"), and alternative +# sources are tried in order. Entries without "file", "url" and "literal" refer to a file of the object's +# name relative to Path=, and an empty "literal" makes an empty file. Files without "contents" are +# acquired by name, as they are, i.e. without decompression. +rm -rf "$WORKDIR/uapi16" +mkdir -p "$WORKDIR/uapi16/payload/blobs" "$WORKDIR/uapi16/manifest/blobs" "$WORKDIR/uapi16/remote" "$WORKDIR/uapi16/target" +head -c 3000 /dev/urandom >"$WORKDIR/uapi16/alt-v1.bin" +cp "$WORKDIR/uapi16/alt-v1.bin" "$WORKDIR/uapi16/payload/blobs/alt-v1.bin" +# A decoy next to the manifest, which must not be used, as "file" entries are not relative to ManifestPath= +head -c 3000 /dev/urandom >"$WORKDIR/uapi16/manifest/blobs/alt-v1.bin" +head -c 2000 /dev/urandom >"$WORKDIR/uapi16/implied-v1.bin" +cp "$WORKDIR/uapi16/implied-v1.bin" "$WORKDIR/uapi16/payload/implied-v1.bin" +: >"$WORKDIR/uapi16/empty-v1.bin" +head -c 5000 /dev/urandom >"$WORKDIR/uapi16/remote-v1.bin" +gzip -k -f "$WORKDIR/uapi16/remote-v1.bin" +mv "$WORKDIR/uapi16/remote-v1.bin.gz" "$WORKDIR/uapi16/remote/" +printf 'inline payload\n' >"$WORKDIR/uapi16/inline-v1.bin" +{ printf 'garbage'; cat "$WORKDIR/uapi16/inline-v1.bin"; printf 'trailing garbage'; } >"$WORKDIR/uapi16/inline-v1.literal" +head -c 4000 /dev/urandom | gzip >"$WORKDIR/uapi16/plain-v1.bin" +cp "$WORKDIR/uapi16/plain-v1.bin" "$WORKDIR/uapi16/payload/plain-v1.bin" +# A disk image like source, which carries the file at an offset, followed by more data +head -c 6000 /dev/urandom >"$WORKDIR/uapi16/slice-v1.bin" +cp "$WORKDIR/uapi16/slice-v1.bin" "$WORKDIR/uapi16/rawslice-v1.bin" +{ head -c 1000 /dev/urandom; cat "$WORKDIR/uapi16/slice-v1.bin"; head -c 2000 /dev/urandom; } >"$WORKDIR/uapi16/remote/disk.raw" +gzip -k -f "$WORKDIR/uapi16/remote/disk.raw" +sha256_of() { sha256sum "$1" | cut -d' ' -f1; } +{ + printf '\036{"mediaType":"application/vnd.uapi.16.manifest"}\n' + # The first source does not exist, the second one is found relative to Path= + printf '\036{"name":"alt-v1.bin","size":%s,"sha256":"%s","contents":[{"file":"blobs/missing-v1.bin"},{"file":"blobs/alt-v1.bin"}]}\n' \ + "$(stat -c %s "$WORKDIR/uapi16/alt-v1.bin")" "$(sha256_of "$WORKDIR/uapi16/alt-v1.bin")" + # The source refers to the file of the object's name relative to Path= implicitly + printf '\036{"name":"implied-v1.bin","size":%s,"sha256":"%s","contents":[{}]}\n' \ + "$(stat -c %s "$WORKDIR/uapi16/implied-v1.bin")" "$(sha256_of "$WORKDIR/uapi16/implied-v1.bin")" + # An empty file carried inline + printf '\036{"name":"empty-v1.bin","size":0,"sha256":"%s","contents":[{"literal":""}]}\n' \ + "$(sha256_of "$WORKDIR/uapi16/empty-v1.bin")" + printf '\036{"name":"remote-v1.bin","size":%s,"sha256":"%s","contents":[{"url":"file://%s/uapi16/remote/remote-v1.bin.gz","encoding":"gzip","encodedSize":%s,"encodedSha256":"%s","originalSize":%s,"originalSha256":"%s"}]}\n' \ + "$(stat -c %s "$WORKDIR/uapi16/remote-v1.bin")" "$(sha256_of "$WORKDIR/uapi16/remote-v1.bin")" "$WORKDIR" \ + "$(stat -c %s "$WORKDIR/uapi16/remote/remote-v1.bin.gz")" "$(sha256_of "$WORKDIR/uapi16/remote/remote-v1.bin.gz")" \ + "$(stat -c %s "$WORKDIR/uapi16/remote-v1.bin")" "$(sha256_of "$WORKDIR/uapi16/remote-v1.bin")" + # The file sits at an offset inside the inline data + printf '\036{"name":"inline-v1.bin","size":%s,"sha256":"%s","contents":[{"literal":"%s","offset":7,"originalSize":%s,"originalSha256":"%s"}]}\n' \ + "$(stat -c %s "$WORKDIR/uapi16/inline-v1.bin")" "$(sha256_of "$WORKDIR/uapi16/inline-v1.bin")" "$(base64 -w0 "$WORKDIR/uapi16/inline-v1.literal")" \ + "$(stat -c %s "$WORKDIR/uapi16/inline-v1.literal")" "$(sha256_of "$WORKDIR/uapi16/inline-v1.literal")" + # No sources listed: acquired by name relative to Path=, and must not be decompressed even though it is + # gzip data, as the digest covers the file as it is + printf '\036{"name":"plain-v1.bin","size":%s,"sha256":"%s"}\n' \ + "$(stat -c %s "$WORKDIR/uapi16/plain-v1.bin")" "$(sha256_of "$WORKDIR/uapi16/plain-v1.bin")" + # The file is a range of the decoded data of a source that is verified as a whole + printf '\036{"name":"slice-v1.bin","size":%s,"sha256":"%s","contents":[{"url":"file://%s/uapi16/remote/disk.raw.gz","encoding":"gzip","encodedSha256":"%s","originalSize":%s,"offset":1000}]}\n' \ + "$(stat -c %s "$WORKDIR/uapi16/slice-v1.bin")" "$(sha256_of "$WORKDIR/uapi16/slice-v1.bin")" "$WORKDIR" \ + "$(sha256_of "$WORKDIR/uapi16/remote/disk.raw.gz")" "$(stat -c %s "$WORKDIR/uapi16/remote/disk.raw")" + # The same, but without a digest of the source, hence only verified against the file's digest + printf '\036{"name":"rawslice-v1.bin","size":%s,"sha256":"%s","contents":[{"url":"file://%s/uapi16/remote/disk.raw","originalSize":%s,"offset":1000}]}\n' \ + "$(stat -c %s "$WORKDIR/uapi16/slice-v1.bin")" "$(sha256_of "$WORKDIR/uapi16/slice-v1.bin")" "$WORKDIR" \ + "$(stat -c %s "$WORKDIR/uapi16/remote/disk.raw")" + # The source matches its digest, but the range at the offset is not the file + printf '\036{"name":"badslice-v1.bin","size":%s,"sha256":"%s","contents":[{"url":"file://%s/uapi16/remote/disk.raw.gz","encoding":"gzip","encodedSha256":"%s","originalSize":%s,"offset":999}]}\n' \ + "$(stat -c %s "$WORKDIR/uapi16/slice-v1.bin")" "$(sha256_of "$WORKDIR/uapi16/slice-v1.bin")" "$WORKDIR" \ + "$(sha256_of "$WORKDIR/uapi16/remote/disk.raw.gz")" "$(stat -c %s "$WORKDIR/uapi16/remote/disk.raw")" + # The encoded data matches its digest, but does not decode to the file + printf '\036{"name":"bad-v1.bin","size":%s,"sha256":"%s","contents":[{"url":"file://%s/uapi16/remote/remote-v1.bin.gz","encoding":"gzip","encodedSha256":"%s"}]}\n' \ + "$(stat -c %s "$WORKDIR/uapi16/remote-v1.bin")" "$(sha256_of "$WORKDIR/uapi16/alt-v1.bin")" "$WORKDIR" \ + "$(sha256_of "$WORKDIR/uapi16/remote/remote-v1.bin.gz")" + printf '\036{"mediaType":"application/vnd.uapi.16.trailer"}\n' +} >"$WORKDIR/uapi16/manifest/Uapi16Manifest" +for f in alt implied empty remote inline plain slice rawslice; do + cat >"$CONFIGDIR/01-uapi16-$f.transfer" <"$WORKDIR/uapi16/bad-definitions/bad.transfer" +(! "$SYSUPDATE" --definitions="$WORKDIR/uapi16/bad-definitions" --verify=no update) +[[ ! -e "$WORKDIR/uapi16/target/bad-v1.bin" ]] +sed -i 's/^MatchPattern=bad-/MatchPattern=badslice-/' "$WORKDIR/uapi16/bad-definitions/bad.transfer" +(! "$SYSUPDATE" --definitions="$WORKDIR/uapi16/bad-definitions" --verify=no update) +[[ ! -e "$WORKDIR/uapi16/target/badslice-v1.bin" ]] + +# systemd-pull cannot acquire a tarball that is embedded in a larger source, and we cannot cut into encoded +# inline data, hence such sources are ignored, and the next source listed is used instead +mkdir -p "$WORKDIR/uapi16/ranged/definitions" "$WORKDIR/uapi16/ranged/tree" "$WORKDIR/uapi16/ranged/dirs" "$WORKDIR/uapi16/ranged/target" +printf 'tarball payload\n' >"$WORKDIR/uapi16/ranged/tree/file.txt" +tar -C "$WORKDIR/uapi16/ranged/tree" -cf "$WORKDIR/uapi16/ranged/tree-v1.tar" . +{ head -c 512 /dev/zero; cat "$WORKDIR/uapi16/ranged/tree-v1.tar"; } >"$WORKDIR/uapi16/ranged/embedded.raw" +printf 'encoded inline payload\n' >"$WORKDIR/uapi16/ranged/enc-v1.bin" +{ printf 'garbage'; cat "$WORKDIR/uapi16/ranged/enc-v1.bin"; } | gzip >"$WORKDIR/uapi16/ranged/enc-v1.literal.gz" +{ + printf '\036{"mediaType":"application/vnd.uapi.16.manifest"}\n' + printf '\036{"name":"tree-v1.tar","size":%s,"sha256":"%s","contents":[{"url":"file://%s/uapi16/ranged/embedded.raw","offset":512},{"url":"file://%s/uapi16/ranged/tree-v1.tar"}]}\n' \ + "$(stat -c %s "$WORKDIR/uapi16/ranged/tree-v1.tar")" "$(sha256_of "$WORKDIR/uapi16/ranged/tree-v1.tar")" "$WORKDIR" "$WORKDIR" + printf '\036{"name":"enc-v1.bin","size":%s,"sha256":"%s","contents":[{"literal":"%s","encoding":"gzip","offset":7},{"literal":"%s"}]}\n' \ + "$(stat -c %s "$WORKDIR/uapi16/ranged/enc-v1.bin")" "$(sha256_of "$WORKDIR/uapi16/ranged/enc-v1.bin")" \ + "$(base64 -w0 "$WORKDIR/uapi16/ranged/enc-v1.literal.gz")" "$(base64 -w0 "$WORKDIR/uapi16/ranged/enc-v1.bin")" + printf '\036{"mediaType":"application/vnd.uapi.16.trailer"}\n' +} >"$WORKDIR/uapi16/ranged/Uapi16Manifest" +cat >"$WORKDIR/uapi16/ranged/definitions/01-tree.transfer" <"$WORKDIR/uapi16/ranged/definitions/02-enc.transfer" <"$WORKDIR/uapi16/ranged/update.log" +grep -F "File 'tree-v1.tar' has a source that carries more than the file, which is not supported for tarballs, ignoring source." "$WORKDIR/uapi16/ranged/update.log" >/dev/null +grep -F "File 'enc-v1.bin' has encoded inline data that carries more than the file, ignoring source." "$WORKDIR/uapi16/ranged/update.log" >/dev/null +cmp "$WORKDIR/uapi16/ranged/tree/file.txt" "$WORKDIR/uapi16/ranged/dirs/tree-v1/file.txt" +cmp "$WORKDIR/uapi16/ranged/enc-v1.bin" "$WORKDIR/uapi16/ranged/target/enc-v1.bin" + +# UAPI.16 manifests carry their signature inline, which is verified against the certificates in the VOA +# hierarchy (role "manifest", context "sysupdate") if verification is enabled +if command -v openssl >/dev/null; then + VOA_OS="$(. /etc/os-release; echo "${ID:-linux}")" + VOA_DIR="/run/voa/$VOA_OS/manifest/sysupdate/x509" + S="$WORKDIR/uapi16/signed" + mkdir -p "$S/payload" "$S/target" "$S/definitions" + head -c 2000 /dev/urandom >"$S/payload/signed-v1.bin" + openssl req -x509 -newkey rsa:2048 -nodes -keyout "$S/good.key" -out "$S/good.crt" -subj /CN=good -days 2 2>/dev/null + openssl req -x509 -newkey rsa:2048 -nodes -keyout "$S/other.key" -out "$S/other.crt" -subj /CN=other -days 2 2>/dev/null + systemd-manifest make --private-key="$S/good.key" --certificate="$S/good.crt" "$S/payload" >"$S/Uapi16Manifest" + systemd-manifest make "$S/payload" >"$S/Unsigned.Uapi16Manifest" + cat >"$S/definitions/signed.transfer" <"$S/unsigned.transfer" + + # No certificates installed: refused + (! "$SYSUPDATE" --definitions="$S/definitions" update) + # Only a different certificate installed: refused + mkdir -p "$VOA_DIR" + cp "$S/other.crt" "$VOA_DIR/test-72-other-certificate.pem" + (! "$SYSUPDATE" --definitions="$S/definitions" update) + [[ ! -e "$S/target/signed-v1.bin" ]] + # The right certificate in a different VOA context: refused + mkdir -p "/run/voa/$VOA_OS/manifest/default/x509" + cp "$S/good.crt" "/run/voa/$VOA_OS/manifest/default/x509/test-72-good-certificate.pem" + (! "$SYSUPDATE" --definitions="$S/definitions" update) + [[ ! -e "$S/target/signed-v1.bin" ]] + rm -r "/run/voa/$VOA_OS/manifest/default" + # The right certificate installed too, but masked: refused + cp "$S/good.crt" "$VOA_DIR/test-72-good-certificate.pem" + mkdir -p "/etc/voa/$VOA_OS/manifest/sysupdate/x509" + ln -s /dev/null "/etc/voa/$VOA_OS/manifest/sysupdate/x509/test-72-good-certificate.pem" + (! "$SYSUPDATE" --definitions="$S/definitions" update) + [[ ! -e "$S/target/signed-v1.bin" ]] + rm -r "/etc/voa/$VOA_OS/manifest" + # Not masked anymore: works + "$SYSUPDATE" --definitions="$S/definitions" update + cmp "$S/payload/signed-v1.bin" "$S/target/signed-v1.bin" + # systemd-manifest can check against the same certificates + systemd-manifest verify --voa-context=sysupdate --field=size,hash "$S/payload" "$S/Uapi16Manifest" + (! systemd-manifest verify --field=size,hash "$S/payload" "$S/Uapi16Manifest") + # An unsigned manifest is refused, unless verification is turned off + rm "$S/target/signed-v1.bin" + mv "$S/unsigned.transfer" "$S/definitions/signed.transfer" + (! "$SYSUPDATE" --definitions="$S/definitions" update) + [[ ! -e "$S/target/signed-v1.bin" ]] + "$SYSUPDATE" --definitions="$S/definitions" --verify=no update + cmp "$S/payload/signed-v1.bin" "$S/target/signed-v1.bin" + rm -r "/run/voa/$VOA_OS/manifest" +fi + +# The download tools decode strictly with the declared algorithm if one is passed to them +PULL=/usr/lib/systemd/systemd-pull +IMPORT=/usr/lib/systemd/systemd-import +"$PULL" raw --direct --verify=no --compression=gzip "file://$WORKDIR/uapi16/remote/remote-v1.bin.gz" "$WORKDIR/uapi16/pull-gzip.bin" +cmp "$WORKDIR/uapi16/remote-v1.bin" "$WORKDIR/uapi16/pull-gzip.bin" +"$PULL" raw --direct --verify=no --compression=auto "file://$WORKDIR/uapi16/remote/remote-v1.bin.gz" "$WORKDIR/uapi16/pull-auto.bin" +cmp "$WORKDIR/uapi16/remote-v1.bin" "$WORKDIR/uapi16/pull-auto.bin" +"$PULL" raw --direct --verify=no --compression=none "file://$WORKDIR/uapi16/remote/remote-v1.bin.gz" "$WORKDIR/uapi16/pull-none.bin" +cmp "$WORKDIR/uapi16/remote/remote-v1.bin.gz" "$WORKDIR/uapi16/pull-none.bin" +(! "$PULL" raw --direct --verify=no --compression=xz "file://$WORKDIR/uapi16/remote/remote-v1.bin.gz" "$WORKDIR/uapi16/pull-xz.bin") +(! "$PULL" raw --direct --verify=no --compression=foobar "file://$WORKDIR/uapi16/remote/remote-v1.bin.gz" "$WORKDIR/uapi16/pull-foobar.bin") +"$IMPORT" raw --direct --compression=gzip - "$WORKDIR/uapi16/import-gzip.bin" <"$WORKDIR/uapi16/remote/remote-v1.bin.gz" +cmp "$WORKDIR/uapi16/remote-v1.bin" "$WORKDIR/uapi16/import-gzip.bin" +(! "$IMPORT" raw --direct --compression=xz - "$WORKDIR/uapi16/import-xz.bin" <"$WORKDIR/uapi16/remote/remote-v1.bin.gz") + +# … and optionally verify the decoded data +"$PULL" raw --direct --verify=no --compression=gzip --verify-decoded="$(sha256_of "$WORKDIR/uapi16/remote-v1.bin")" \ + "file://$WORKDIR/uapi16/remote/remote-v1.bin.gz" "$WORKDIR/uapi16/pull-decoded.bin" +cmp "$WORKDIR/uapi16/remote-v1.bin" "$WORKDIR/uapi16/pull-decoded.bin" +(! "$PULL" raw --direct --verify=no --compression=gzip --verify-decoded="$(sha256_of "$WORKDIR/uapi16/alt-v1.bin")" \ + "file://$WORKDIR/uapi16/remote/remote-v1.bin.gz" "$WORKDIR/uapi16/pull-decoded-bad.bin") +"$IMPORT" raw --direct --compression=gzip --verify-decoded="$(sha256_of "$WORKDIR/uapi16/remote-v1.bin")" \ + - "$WORKDIR/uapi16/import-decoded.bin" <"$WORKDIR/uapi16/remote/remote-v1.bin.gz" +cmp "$WORKDIR/uapi16/remote-v1.bin" "$WORKDIR/uapi16/import-decoded.bin" +(! "$IMPORT" raw --direct --compression=gzip --verify-decoded="$(sha256_of "$WORKDIR/uapi16/alt-v1.bin")" \ + - "$WORKDIR/uapi16/import-decoded-bad.bin" <"$WORKDIR/uapi16/remote/remote-v1.bin.gz") +(! "$IMPORT" raw --direct --verify-decoded=foobar - "$WORKDIR/uapi16/import-decoded-invalid.bin" <"$WORKDIR/uapi16/remote-v1.bin") + +rm -f "$CONFIGDIR"/01-uapi16-*.transfer +rm -rf "$WORKDIR/uapi16" + +# Check MatchXAttr=: instances restricted to other systems via "user.uapi.match_…" extended attributes are +# ignored, both when listed in UAPI.16 manifests and when found on disk, and the extended attributes listed +# in the manifest are applied to the installed instance. +XM="$WORKDIR/xattr-match" +rm -rf "$XM" +mkdir -p "$XM/definitions" "$XM/definitions-all" "$XM/definitions-target" "$XM/payload/blobs" "$XM/manifest" "$XM/target" +for v in 1 2 3; do + echo "xattr match payload v$v" >"$XM/payload/blobs/xm-v$v.bin" +done +XM_MID="$(cat /etc/machine-id)" +XM_OSID="$(. /etc/os-release; echo "$ID")" +XM_OTHER=0123456789abcdef0123456789abcdef +xm_object() { + local v="${1:?}" xattrs="" + + [[ -n "${2:-}" ]] && xattrs=",\"extendedAttributes\":{\"user\":{$2}}" + printf '\036{"name":"xm-v%s.bin","size":%s,"sha256":"%s","contents":[{"file":"blobs/xm-v%s.bin"}]%s}\n' \ + "$v" "$(stat -c %s "$XM/payload/blobs/xm-v$v.bin")" "$(sha256_of "$XM/payload/blobs/xm-v$v.bin")" "$v" "$xattrs" +} +{ + printf '\036{"mediaType":"application/vnd.uapi.16.manifest"}\n' + # Not restricted in any way + xm_object 1 + # Restricted to two machines, one of which is us, and to our OS. Also carries an unrelated attribute. + xm_object 2 "$(printf '"uapi.match_machine_id":{"base64":"%s"},"uapi.match_os_id":{"text":"%s"},"foo":{"text":"bar"}' \ + "$(printf '%s\0%s\0' "$XM_OTHER" "$XM_MID" | base64 -w0)" "$XM_OSID")" + # Restricted to some other machine + xm_object 3 "$(printf '"uapi.match_machine_id":{"text":"%s"}' "$XM_OTHER")" + printf '\036{"mediaType":"application/vnd.uapi.16.trailer"}\n' +} >"$XM/manifest/Uapi16Manifest" +for d in definitions definitions-all definitions-target; do + cat >"$XM/$d/xm.transfer" <>"$XM/definitions-all/xm.transfer" <>"$XM/definitions-target/xm.transfer" <"$XM/target/xm-v4.bin" +setfattr -n user.uapi.match_machine_id -v "$XM_OTHER" "$XM/target/xm-v4.bin" +[[ $("$SYSUPDATE" --definitions="$XM/definitions-all" --verify=no --offline --json=short list | jq -cr '.all') == '["v4","v2"]' ]] +[[ $("$SYSUPDATE" --definitions="$XM/definitions" --verify=no --offline --json=short list | jq -cr '.all') == '["v4","v2"]' ]] +[[ $("$SYSUPDATE" --definitions="$XM/definitions-target" --verify=no --offline --json=short list | jq -cr '.all') == '["v2"]' ]] +"$SYSUPDATE" --definitions="$XM/definitions-target" --verify=no vacuum +[[ -e "$XM/target/xm-v2.bin" ]] +[[ -e "$XM/target/xm-v4.bin" ]] +rm -rf "$XM" + # Check that malformed manifest hashes are rejected without aborting. rm -rf "$WORKDIR/malformed-manifest" mkdir -p "$WORKDIR/malformed-manifest/definitions" "$WORKDIR/malformed-manifest/source" "$WORKDIR/malformed-manifest/target"