From: Lennart Poettering Date: Fri, 7 Nov 2025 07:35:59 +0000 (+0100) Subject: pull: add OCI support X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=a9f6ba04969d6eb2e629e30299fab7538ef42a57;p=thirdparty%2Fsystemd.git pull: add OCI support --- diff --git a/meson_options.txt b/meson_options.txt index 4d13d2866d2..c1af7ce2374 100644 --- a/meson_options.txt +++ b/meson_options.txt @@ -565,3 +565,6 @@ option('vmlinux-h-path', type : 'string', value : '', option('default-mountfsd-trusted-directories', type : 'boolean', value: false, description : 'controls whether mountfsd should apply a relaxed policy on DDIs in system DDI directories') + +option('default-oci-registry', type : 'string', value : 'docker.io', + description : 'Default OCI registry name') diff --git a/src/import/import-common.h b/src/import/import-common.h index b568906a8a6..6b10f8c29db 100644 --- a/src/import/import-common.h +++ b/src/import/import-common.h @@ -23,13 +23,14 @@ typedef enum ImportFlags { IMPORT_PULL_ROOTHASH_SIGNATURE = 1 << 11, /* only for raw: download .roothash.p7s file for verity */ IMPORT_PULL_VERITY = 1 << 12, /* only for raw: download .verity file for verity */ - /* The supported flags for the tar and the raw importing */ + /* The supported flags for the tar and raw importing */ IMPORT_FLAGS_MASK_TAR = IMPORT_FORCE|IMPORT_READ_ONLY|IMPORT_BTRFS_SUBVOL|IMPORT_BTRFS_QUOTA|IMPORT_DIRECT|IMPORT_SYNC|IMPORT_FOREIGN_UID, IMPORT_FLAGS_MASK_RAW = IMPORT_FORCE|IMPORT_READ_ONLY|IMPORT_CONVERT_QCOW2|IMPORT_DIRECT|IMPORT_SYNC, - /* The supported flags for the tar and the raw pulling */ + /* The supported flags for the tar, raw, oci pulling */ IMPORT_PULL_FLAGS_MASK_TAR = IMPORT_FLAGS_MASK_TAR|IMPORT_PULL_KEEP_DOWNLOAD|IMPORT_PULL_SETTINGS, IMPORT_PULL_FLAGS_MASK_RAW = IMPORT_FLAGS_MASK_RAW|IMPORT_PULL_KEEP_DOWNLOAD|IMPORT_PULL_SETTINGS|IMPORT_PULL_ROOTHASH|IMPORT_PULL_ROOTHASH_SIGNATURE|IMPORT_PULL_VERITY, + IMPORT_PULL_FLAGS_MASK_OCI = IMPORT_FORCE|IMPORT_READ_ONLY|IMPORT_BTRFS_SUBVOL|IMPORT_BTRFS_QUOTA|IMPORT_SYNC|IMPORT_FOREIGN_UID|IMPORT_PULL_SETTINGS, _IMPORT_FLAGS_INVALID = -EINVAL, } ImportFlags; diff --git a/src/import/meson.build b/src/import/meson.build index 46333fb15fe..7735db7ff5e 100644 --- a/src/import/meson.build +++ b/src/import/meson.build @@ -20,6 +20,7 @@ executables += [ 'importd.c', ), 'extract' : files( + 'oci-util.c', 'import-common.c', 'import-compress.c', 'qcow2-util.c', @@ -30,12 +31,13 @@ executables += [ 'name' : 'systemd-pull', 'public' : true, 'sources' : files( + 'curl-util.c', 'pull.c', + 'pull-common.c', + 'pull-job.c', + 'pull-oci.c', 'pull-raw.c', 'pull-tar.c', - 'pull-job.c', - 'pull-common.c', - 'curl-util.c', ), 'objects' : ['systemd-importd'], 'dependencies' : common_deps + [ @@ -92,7 +94,11 @@ executables += [ 'dependencies' : common_deps, 'type' : 'manual', }, - + test_template + { + 'sources' : files('test-oci-util.c'), + 'objects': ['systemd-importd'], + 'dependencies' : common_deps, + }, ] install_data('org.freedesktop.import1.conf', @@ -108,3 +114,28 @@ install_data('org.freedesktop.import1.policy', install_data('import-pubring.pgp', install_dir : libexecdir) # TODO: shouldn't this be in pkgdatadir? + +oci_registry_files = [ + 'registry.docker.io.oci-registry', + 'registry.fedora.oci-registry', +] +oci_registry_symlinks = [ + [ 'default.oci-registry', 'registry.' + get_option('default-oci-registry') + '.oci-registry' ], + [ 'image.alpine.oci-registry', 'registry.docker.io.oci-registry' ], + [ 'image.archlinux.oci-registry', 'registry.docker.io.oci-registry' ], + [ 'image.debian.oci-registry', 'registry.docker.io.oci-registry' ], + [ 'image.fedora-minimal.oci-registry', 'registry.fedora.oci-registry' ], + [ 'image.fedora.oci-registry', 'registry.fedora.oci-registry' ], + [ 'image.ubuntu.oci-registry', 'registry.docker.io.oci-registry' ], +] + +foreach file : oci_registry_files + install_data('oci-registry' / file, + install_dir : libexecdir / 'oci-registry') +endforeach + +foreach tuple: oci_registry_symlinks + install_symlink(tuple[0], + pointing_to : tuple[1], + install_dir : libexecdir / 'oci-registry') +endforeach diff --git a/src/import/oci-registry/registry.docker.io.oci-registry b/src/import/oci-registry/registry.docker.io.oci-registry new file mode 100644 index 00000000000..a36b54eb904 --- /dev/null +++ b/src/import/oci-registry/registry.docker.io.oci-registry @@ -0,0 +1,4 @@ +{ + "shortImagePrefix" : "library/", + "overrideRegistry" : "registry-1.docker.io" +} diff --git a/src/import/oci-registry/registry.fedora.oci-registry b/src/import/oci-registry/registry.fedora.oci-registry new file mode 100644 index 00000000000..e416bf28535 --- /dev/null +++ b/src/import/oci-registry/registry.fedora.oci-registry @@ -0,0 +1,3 @@ +{ + "defaultRegistry" : "registry.fedoraproject.org" +} diff --git a/src/import/oci-util.c b/src/import/oci-util.c new file mode 100644 index 00000000000..37a617b28ce --- /dev/null +++ b/src/import/oci-util.c @@ -0,0 +1,401 @@ +/* SPDX-License-Identifier: LGPL-2.1-or-later */ + +#include + +#include "sd-json.h" + +#include "alloc-util.h" +#include "architecture.h" +#include "constants.h" +#include "dns-domain.h" +#include "fd-util.h" +#include "fileio.h" +#include "hexdecoct.h" +#include "log.h" +#include "oci-util.h" +#include "parse-util.h" +#include "string-table.h" +#include "string-util.h" + +bool oci_image_is_valid(const char *n) { + bool slash = true; + + /* The OCI spec suggests validating this regex: + * + * [a-z0-9]+((\.|_|__|-+)[a-z0-9]+)*(\/[a-z0-9]+((\.|_|__|-+)[a-z0-9]+)*)* + * + * We implement a generalization of this, i.e. do not insist on the single ".", "_", "__", "-", "+" + * separator, but allow any number of them. And we refuse leading dots, since if used in the fs this + * would make the files hidden, and we probably don't want that. + */ + + for (const char *p = n; *p; p++) { + if (*p == '/') { + if (slash) + return false; + + slash = true; + continue; + } + + if (!strchr(slash ? LOWERCASE_LETTERS DIGITS "_-+" : + "." LOWERCASE_LETTERS DIGITS "_-+", *p)) + return false; + + slash = false; + } + + return !slash; +} + +int oci_registry_is_valid(const char *n) { + int r; + + if (!n) + return false; + + const char *colon = strchr(n, ':'); + if (!colon) + return dns_name_is_valid(n); + + _cleanup_free_ char *s = strndup(n, colon - n); + if (!s) + return -ENOMEM; + + r = dns_name_is_valid(s); + if (r <= 0) + return r; + + uint16_t port; + return safe_atou16(s, &port) >= 0 && port != 0; +} + +bool oci_tag_is_valid(const char *n) { + if (!n) + return false; + + /* As per https://github.com/opencontainers/distribution-spec/blob/main/spec.md, accept the following regex: + * + * [a-zA-Z0-9_][a-zA-Z0-9._-]{0,127} + */ + + if (!strchr(LETTERS DIGITS "_", n[0])) + return false; + + size_t l = strspn(n + 1, LETTERS DIGITS "._-"); + if (l > 126) + return false; + if (n[1+l] != 0) + return false; + + return true; +} + +int oci_ref_parse( + const char *ref, + char **ret_registry, + char **ret_image, + char **ret_tag) { + + int r; + + assert(ref); + + _cleanup_free_ char *without_tag = NULL, *tag = NULL; + const char *t = strrchr(ref, ':'); + if (t) { + tag = strdup(t + 1); + if (!tag) + return -ENOMEM; + if (!oci_tag_is_valid(tag)) + return log_debug_errno(SYNTHETIC_ERRNO(EINVAL), "OCI tag specification '%s' is not valid.", tag); + + without_tag = strndup(ref, t - ref); + if (!without_tag) + return -ENOMEM; + + ref = without_tag; + } + + _cleanup_free_ char *image = NULL, *registry = NULL; + t = strchr(ref, '/'); + if (t) { + registry = strndup(ref, t - ref); + if (!registry) + return -ENOMEM; + + r = oci_registry_is_valid(registry); + if (r < 0) + return r; + if (r == 0) + return log_debug_errno(SYNTHETIC_ERRNO(EINVAL), "OCI registry specification '%s' is not valid.", registry); + + image = strdup(t + 1); + } else + image = strdup(ref); + if (!image) + return -ENOMEM; + if (!oci_image_is_valid(image)) + return log_debug_errno(SYNTHETIC_ERRNO(EINVAL), "OCI image specification '%s' is not valid.", registry); + + if (ret_registry) + *ret_registry = TAKE_PTR(registry); + if (ret_image) + *ret_image = TAKE_PTR(image); + if (ret_tag) + *ret_tag = TAKE_PTR(tag); + + return 0; +} + +int oci_ref_normalize(char **protocol, char **registry, char **image, char **tag) { + int r; + + assert(protocol); + assert(registry); + assert(image && *image); + assert(tag); + + /* OCI container reference are supposed to have the form /:. Except that it's + * all super messy, and for some registries the server name differs from the name people use in the + * references, and there are special rules for "short" container names (i.e. those which do not + * contain a "/"), and more. To deal with this, we devise a relatively simple scheme, to normalize + * such names. Specifically: + * + * If a registry is specified we look for + * /usr/lib/systemd/oci-registry/registry..oci-registry for registry-specific rules, to + * enforce on the reference. If no registry is specified, we look for an + * /usr/lib/systemd/oci-registry/image..oci-registry file, which contains image-specific rules + * instead. If this is not found we load /usr/lib/systemd/oci-registry/default.oci-registry + * instead. The files are encoded in JSON. + * + * The rules we apply are relatively simple: + * + * • defaultProtocol controls which protocol to use if none is known. This should always be https + * (since OCI images are authenticated purely via HTTPS), but for testing purposes "file" might be + * useful too. + * + * • overrideRegistry encodes which registry server to actually use, overriding what might have been + * specified. + * + * • overrideImage encodes which image name to actually use, overriding what might have been specified. + * + * • shortImagePrefix encodes a name prefix to prepend to "short" container names. This has no effect + * if overrideImage is set too. + * + * • defaultTag contains a tag to use as default, if none is specified. If not configured this + * defaults to "latest". + */ + + _cleanup_free_ char *fn = NULL; + if (*registry) { + /* If a registry is specified, we'll always respect it, and use it as only search key */ + _cleanup_free_ char *e = urlescape(*registry); + if (!e) + return -ENOMEM; + + fn = strjoin("registry.", e, ".oci-registry"); + } else { + /* If no registry is specified, let's go by image name */ + _cleanup_free_ char *e = urlescape(*image); + if (!e) + return -ENOMEM; + + fn = strjoin("image.", e, ".oci-registry"); + } + if (!fn) + return -ENOMEM; + + _cleanup_fclose_ FILE *f = NULL; + _cleanup_free_ char *path = NULL; + r = search_and_fopen_nulstr(fn, "re", /* root= */ NULL, CONF_PATHS_NULSTR("systemd/oci-registry"), &f, &path); + if (r == -ENOENT) + r = search_and_fopen_nulstr("default.oci-registry", "re", /* root= */ NULL, CONF_PATHS_NULSTR("systemd/oci-registry"), &f, &path); + if (r < 0 && r != -ENOENT) + return log_debug_errno(r, "Failed to find suitable OCI registry file: %m"); + + /* if ENOENT is seen, we use the defaults below! */ + + struct { + const char *default_protocol; + const char *override_registry; + const char *default_registry; + const char *override_image; + const char *short_image_prefix; + const char *default_tag; + } data = { + .default_protocol = "https", + .default_tag = "latest", + }; + + _cleanup_(sd_json_variant_unrefp) sd_json_variant *v = NULL; + if (f) { + unsigned line = 0, column = 0; + r = sd_json_parse_file(f, path, /* flags= */ 0, &v, &line, &column); + if (r < 0) + return log_debug_errno(r, "Parse failure at %s:%u:%u: %m", path, line, column); + + static const sd_json_dispatch_field table[] = { + { "defaultProtocol", SD_JSON_VARIANT_STRING, sd_json_dispatch_const_string, voffsetof(data, default_protocol), 0 }, + { "overrideRegistry", SD_JSON_VARIANT_STRING, sd_json_dispatch_const_string, voffsetof(data, override_registry), 0 }, + { "defaultRegistry", SD_JSON_VARIANT_STRING, sd_json_dispatch_const_string, voffsetof(data, default_registry), 0 }, + { "overrideImage", SD_JSON_VARIANT_STRING, sd_json_dispatch_const_string, voffsetof(data, override_image), 0 }, + { "shortImagePrefix", SD_JSON_VARIANT_STRING, sd_json_dispatch_const_string, voffsetof(data, short_image_prefix), 0 }, + { "defaultTag", SD_JSON_VARIANT_STRING, sd_json_dispatch_const_string, voffsetof(data, default_tag), 0 }, + {}, + }; + r = sd_json_dispatch(v, table, SD_JSON_ALLOW_EXTENSIONS, &data); + if (r < 0) + return r; + } + + _cleanup_free_ char *new_protocol = NULL; + if (data.default_protocol && isempty(*protocol)) { + new_protocol = strdup(data.default_protocol); + if (!new_protocol) + return -ENOMEM; + } + + _cleanup_free_ char *new_registry = NULL; + if (data.override_registry) { + if (!isempty(*registry)) + log_debug("Overriding registry to '%s' (was '%s') based on OCI registry database.", data.override_registry, *registry); + + new_registry = strdup(data.override_registry); + if (!new_registry) + return -ENOMEM; + } else if (data.default_registry && isempty(*registry)) { + new_registry = strdup(data.default_registry); + if (!new_registry) + return -ENOMEM; + } + + _cleanup_free_ char *new_image = NULL; + if (data.override_image) { + log_debug("Overriding image to '%s' (was '%s') based on OCI registry database.", data.override_registry, *image); + + new_image = strdup(data.override_image); + if (!new_image) + return -ENOMEM; + } else if (data.short_image_prefix && !strchr(*image, '/')) { + new_image = strjoin(data.short_image_prefix, *image); + if (!new_image) + return -ENOMEM; + } + + _cleanup_free_ char *new_tag = NULL; + if (data.default_tag && isempty(*tag)) { + new_tag = strdup(data.default_tag); + if (!new_tag) + return -ENOMEM; + } + + if (!new_registry && isempty(*registry)) + return log_debug_errno(SYNTHETIC_ERRNO(ENODATA), "No suitable registry found."); + + if (new_protocol) + free_and_replace(*protocol, new_protocol); + if (new_registry) + free_and_replace(*registry, new_registry); + if (new_image) + free_and_replace(*image, new_image); + if (new_tag) + free_and_replace(*tag, new_tag); + + return 0; +} + +char* oci_digest_string(const struct iovec *iovec) { + assert(iovec); + + _cleanup_free_ char *h = hexmem(iovec->iov_base, iovec->iov_len); + if (!h) + return NULL; + + return strjoin("sha256:", h); +} + +int oci_make_manifest_url( + const char *protocol, + const char *repository, + const char *image, + const char *tag, + char **ret) { + + assert(protocol); + assert(repository); + assert(image); + assert(tag); + assert(ret); + + _cleanup_free_ char *url = strjoin(protocol, "://", repository, "/v2/", image, "/manifests/", tag); + if (!url) + return -ENOMEM; + + *ret = TAKE_PTR(url); + return 0; +} + +int oci_make_blob_url( + const char *protocol, + const char *repository, + const char *image, + const struct iovec *digest, + char **ret) { + + assert(protocol); + assert(repository); + assert(image); + assert(digest); + assert(ret); + + _cleanup_free_ char *d = oci_digest_string(digest); + if (!d) + return -ENOMEM; + + _cleanup_free_ char *url = strjoin(protocol, "://", repository, "/v2/", image, "/blobs/", d); + if (!url) + return -ENOMEM; + + *ret = TAKE_PTR(url); + return 0; +} + +/* OCI uses the Go architecture IDs */ +static const char *const go_arch_table[_ARCHITECTURE_MAX] = { + [ARCHITECTURE_ARM] = "arm", + [ARCHITECTURE_ARM64] = "arm64", + [ARCHITECTURE_MIPS] = "mips", + [ARCHITECTURE_MIPS64] = "mips64", + [ARCHITECTURE_MIPS64_LE] = "mips64le", + [ARCHITECTURE_MIPS_LE] = "mipsle", + [ARCHITECTURE_PPC64] = "ppc64", + [ARCHITECTURE_PPC64_LE] = "ppc64le", + [ARCHITECTURE_S390X] = "s390x", + [ARCHITECTURE_X86] = "386", + [ARCHITECTURE_X86_64] = "amd64", +}; + +DEFINE_STRING_TABLE_LOOKUP_FROM_STRING(go_arch, Architecture); + +char* urlescape(const char *s) { + size_t l = strlen_ptr(s); + + _cleanup_free_ char *t = new(char, l * 3 + 1); + if (!t) + return NULL; + + char *p = t; + for (; s && *s; s++) { + if (strchr(LETTERS DIGITS ".-_", *s)) + *(p++) = *s; + else { + *(p++) = '%'; + *(p++) = hexchar((uint8_t) *s >> 4); + *(p++) = hexchar((uint8_t) *s & 15); + } + } + + *p = 0; + return TAKE_PTR(t); +} diff --git a/src/import/oci-util.h b/src/import/oci-util.h new file mode 100644 index 00000000000..72b63700571 --- /dev/null +++ b/src/import/oci-util.h @@ -0,0 +1,31 @@ +/* SPDX-License-Identifier: LGPL-2.1-or-later */ +#pragma once + +#include "basic-forward.h" + +bool oci_image_is_valid(const char *n); +int oci_registry_is_valid(const char *n); +bool oci_tag_is_valid(const char *n); + +int oci_ref_parse(const char *ref, char **ret_registry, char **ret_image, char **ret_tag); + +static inline int oci_ref_valid(const char *ref) { + int r; + r = oci_ref_parse(ref, NULL, NULL, NULL); + if (r == -EINVAL) + return false; + if (r < 0) + return r; + return true; +} + +int oci_ref_normalize(char **protocol, char **registry, char **image, char **tag); + +char* oci_digest_string(const struct iovec *iovec); + +int oci_make_manifest_url(const char *protocol, const char *repository, const char *image, const char *tag, char **ret); +int oci_make_blob_url(const char *protocol, const char *repository, const char *image, const struct iovec *digest, char **ret); + +Architecture go_arch_from_string(const char *s); + +char* urlescape(const char *s); diff --git a/src/import/pull-oci.c b/src/import/pull-oci.c new file mode 100644 index 00000000000..d55bd43f570 --- /dev/null +++ b/src/import/pull-oci.c @@ -0,0 +1,1585 @@ +/* SPDX-License-Identifier: LGPL-2.1-or-later */ + +#include "sd-event.h" +#include "sd-json.h" +#include "sd-varlink.h" + +#include "alloc-util.h" +#include "architecture.h" +#include "btrfs-util.h" +#include "cleanup-util.h" +#include "curl-util.h" +#include "dissect-image.h" +#include "errno-util.h" +#include "escape.h" +#include "extract-word.h" +#include "fd-util.h" +#include "fs-util.h" +#include "hexdecoct.h" +#include "install-file.h" +#include "io-util.h" +#include "json-util.h" +#include "mkdir-label.h" +#include "oci-util.h" +#include "ordered-set.h" +#include "path-util.h" +#include "pidref.h" +#include "process-util.h" +#include "pull-common.h" +#include "pull-oci.h" +#include "rm-rf.h" +#include "set.h" +#include "signal-util.h" +#include "stat-util.h" +#include "string-util.h" +#include "strv.h" +#include "tmpfile-util.h" + +#define LAYER_MAX 4096U +#define ACTIVE_LAYERS_MAX 5U + +typedef enum OciProgress { + OCI_DOWNLOADING_MANIFEST, + OCI_DOWNLOADING_LAYERS, + OCI_FINALIZING, + OCI_COPYING, +} OciProgress; + +typedef struct OciPull { + sd_event *event; + CurlGlue *glue; + + ImportFlags flags; + char *image_root; + + char *protocol; + char *repository; + char *image; + char *tag; + + PullJob *manifest_job; + PullJob *bearer_token_job; + PullJob *config_job; + OrderedSet *queued_layer_jobs; + Set *active_layer_jobs, *done_layer_jobs; + + OciPullFinished on_finished; + void *userdata; + + bool refuse_index; /* if true, refuse processing an OCI index, because we already processed one */ + + char *bearer_token; + + char *local; + + struct iovec *layer_digests; + size_t n_layers; + + struct iovec config; + int userns_fd; +} OciPull; + +typedef struct OciLayerState { + OciPull *pull; + + PidRef tar_pid; + char *temp_path; + char *final_path; + + int tree_fd; +} OciLayerState; + +static void oci_pull_job_on_finished_layer(PullJob *j); +static void oci_pull_job_on_finished_manifest(PullJob *j); +static void oci_pull_job_on_finished_config(PullJob *j); +static int oci_pull_work(OciPull *i); + +static OciLayerState* oci_layer_state_free(OciLayerState *st) { + if (!st) + return NULL; + + pidref_done_sigkill_wait(&st->tar_pid); + + if (st->temp_path) { + import_remove_tree(st->temp_path, st->pull ? &st->pull->userns_fd : NULL, st->pull->flags); + free(st->temp_path); + } + free(st->final_path); + + safe_close(st->tree_fd); + + return mfree(st); +} + +DEFINE_TRIVIAL_CLEANUP_FUNC(OciLayerState*, oci_layer_state_free); + +OciPull* oci_pull_unref(OciPull *i) { + if (!i) + return NULL; + + pull_job_unref(i->manifest_job); + pull_job_unref(i->bearer_token_job); + pull_job_unref(i->config_job); + ordered_set_free(i->queued_layer_jobs); + set_free(i->active_layer_jobs); + set_free(i->done_layer_jobs); + + curl_glue_unref(i->glue); + sd_event_unref(i->event); + + free(i->protocol); + free(i->repository); + free(i->image); + free(i->tag); + + free(i->image_root); + free(i->local); + free(i->bearer_token); + + iovec_done(&i->config); + + iovec_done_many_and_free(i->layer_digests, i->n_layers); + + safe_close(i->userns_fd); + + return mfree(i); +} + +int oci_pull_new( + OciPull **ret, + sd_event *event, + const char *image_root, + OciPullFinished on_finished, + void *userdata) { + + int r; + + assert(image_root); + assert(ret); + + _cleanup_free_ char *root = strdup(image_root); + if (!root) + return -ENOMEM; + + _cleanup_(sd_event_unrefp) sd_event *e = NULL; + if (event) + e = sd_event_ref(event); + else { + r = sd_event_default(&e); + if (r < 0) + return r; + } + + _cleanup_(curl_glue_unrefp) CurlGlue *g = NULL; + r = curl_glue_new(&g, e); + if (r < 0) + return r; + + _cleanup_(oci_pull_unrefp) OciPull *i = NULL; + i = new(OciPull, 1); + if (!i) + return -ENOMEM; + + *i = (OciPull) { + .on_finished = on_finished, + .userdata = userdata, + .image_root = TAKE_PTR(root), + .event = TAKE_PTR(e), + .glue = TAKE_PTR(g), + .userns_fd = -EBADF, + }; + + i->glue->on_finished = pull_job_curl_on_finished; + i->glue->userdata = i; + + *ret = TAKE_PTR(i); + + return 0; +} + +static int pull_job_payload_as_json(PullJob *j, sd_json_variant **ret) { + int r; + + assert(j); + assert(ret); + + /* The PullJob logic implicitly NUL terminates */ + assert(((char*) j->payload.iov_base)[j->payload.iov_len] == 0); + + if (memchr(j->payload.iov_base, 0, j->payload.iov_len)) + return log_error_errno(SYNTHETIC_ERRNO(EBADMSG), "Embedded NUL byte in JSON data, refusing."); + + _cleanup_(sd_json_variant_unrefp) sd_json_variant *v = NULL; + unsigned line = 0, column = 0; + r = sd_json_parse((char*) j->payload.iov_base, /* flags= */ 0, &v, &line, &column); + if (r < 0) + return log_error_errno(r, "Failed to parse JSON at position %u:%u: %m", line, column); + + if (DEBUG_LOGGING) + sd_json_variant_dump(v, SD_JSON_FORMAT_COLOR_AUTO|SD_JSON_FORMAT_PRETTY_AUTO, /* f= */ NULL, /* prefix= */ NULL); + + *ret = TAKE_PTR(v); + return 0; +} + +typedef struct OciIndexEntry { + char *media_type; + struct iovec digest; + uint64_t size; + char **url; + char *os; + char *architecture; + char *variant; +} OciIndexEntry; + +static void oci_index_entry_done(OciIndexEntry *entry) { + assert(entry); + + entry->media_type = mfree(entry->media_type); + iovec_done(&entry->digest); + entry->url = strv_free(entry->url); + entry->os = mfree(entry->os); + entry->architecture = mfree(entry->architecture); + entry->variant = mfree(entry->variant); +} + +static bool oci_index_entry_match(OciIndexEntry *entry) { + assert(entry); + + if (entry->os && !streq(entry->os, "linux")) + return false; + + if (entry->architecture && go_arch_from_string(entry->architecture) != native_architecture()) + return false; + + return true; +} + +static int json_dispatch_oci_digest(const char *name, sd_json_variant *variant, sd_json_dispatch_flags_t flags, void *userdata) { + struct iovec *field = ASSERT_PTR(userdata); + int r; + + assert(variant); + + const char *s = NULL; + r = sd_json_dispatch_const_string(name, variant, flags, &s); + if (r < 0) + return r; + + const char *h = startswith(s, "sha256:"); + if (!h) + return json_log(variant, flags, SYNTHETIC_ERRNO(EOPNOTSUPP), "Unsupported hash algorithm used in '%s', refusing.", s); + + _cleanup_(iovec_done) struct iovec d = {}; + r = unhexmem(h, &d.iov_base, &d.iov_len); + if (r < 0) + return json_log(variant, flags, r, "Failed to decode hash '%s', refusing.", s); + if (d.iov_len != SHA256_DIGEST_SIZE) + return json_log(variant, flags, SYNTHETIC_ERRNO(EINVAL), "Hash '%s' has wrong size, refusing.", s); + + iovec_done(field); + *field = TAKE_STRUCT(d); + + return 0; +} + +static int json_dispatch_oci_platform(const char *name, sd_json_variant *variant, sd_json_dispatch_flags_t flags, void *userdata) { + OciIndexEntry *entry = ASSERT_PTR(userdata); + + assert(variant); + + static const struct sd_json_dispatch_field table[] = { + { "os", SD_JSON_VARIANT_STRING, sd_json_dispatch_string, offsetof(OciIndexEntry, os), 0 }, + { "architecture", SD_JSON_VARIANT_STRING, sd_json_dispatch_string, offsetof(OciIndexEntry, architecture), 0 }, + { "variant", SD_JSON_VARIANT_STRING, sd_json_dispatch_string, offsetof(OciIndexEntry, variant), 0 }, + {} + }; + + return sd_json_dispatch(variant, table, flags|SD_JSON_ALLOW_EXTENSIONS, entry); +} + +static int json_dispatch_oci_index_entry(sd_json_variant *v, OciIndexEntry *entry) { + int r; + + assert(v); + assert(entry); + + static const struct sd_json_dispatch_field dispatch_table[] = { + { "mediaType", SD_JSON_VARIANT_STRING, sd_json_dispatch_string, offsetof(OciIndexEntry, media_type), SD_JSON_MANDATORY }, + { "digest", SD_JSON_VARIANT_STRING, json_dispatch_oci_digest, offsetof(OciIndexEntry, digest), SD_JSON_MANDATORY }, + { "size", SD_JSON_VARIANT_UNSIGNED, sd_json_dispatch_uint64, offsetof(OciIndexEntry, size), SD_JSON_MANDATORY }, + { "urls", SD_JSON_VARIANT_ARRAY, sd_json_dispatch_strv, offsetof(OciIndexEntry, url), 0 }, + { "platform", SD_JSON_VARIANT_OBJECT, json_dispatch_oci_platform, 0, 0 }, + {} + }; + + r = sd_json_dispatch(v, dispatch_table, SD_JSON_ALLOW_EXTENSIONS|SD_JSON_LOG, entry); + if (r < 0) + return r; + + if (!streq_ptr(entry->media_type, "application/vnd.oci.image.manifest.v1+json")) { + json_log(v, SD_JSON_LOG, /* error= */ 0, "Unexpected manifest index entry with media type '%s', skipping.", entry->media_type); + return 0; + } + + return 1; +} + +static int oci_pull_redirect_manifest(OciPull *i, const OciIndexEntry *entry) { + int r; + + assert(i); + assert(entry); + + /* We acquired an index already and found the right manifest to select, hence let's acquire that one + * now */ + + _cleanup_free_ char *p = oci_digest_string(&entry->digest); + if (!p) + return -ENOMEM; + + _cleanup_free_ char *url = NULL; + r = oci_make_manifest_url(i->protocol, i->repository, i->image, p, &url); + if (r < 0) + return r; + + _cleanup_(pull_job_unrefp) PullJob *j = NULL; + r = pull_job_new(&j, url, i->glue, i); + if (r < 0) + return r; + + r = pull_job_set_accept( + j, + STRV_MAKE("application/vnd.oci.image.manifest.v1+json")); + if (r < 0) + return r; + + if (i->bearer_token) { + r = pull_job_set_bearer_token(j, i->bearer_token); + if (r < 0) + return r; + } + + j->on_finished = oci_pull_job_on_finished_manifest; + j->calc_checksum = true; + if (!iovec_memdup(&entry->digest, &j->checksum)) + return -ENOMEM; + + j->description = strjoin("Image Manifest (", url, ")"); + if (!j->description) + return -ENOMEM; + + r = pull_job_begin(j); + if (r < 0) + return r; + + free_and_replace_full(i->manifest_job, j, pull_job_unref); + return 0; +} + +static int oci_pull_process_index(OciPull *i, PullJob *j) { + int r; + + assert(i); + assert(j); + + /* Processes a just downloaded OCI image index, as per: + * + * https://github.com/opencontainers/image-spec/blob/main/image-index.md */ + + _cleanup_(sd_json_variant_unrefp) sd_json_variant *v = NULL; + r = pull_job_payload_as_json(j, &v); + if (r < 0) + return r; + + struct { + unsigned schema_version; + const char *media_type; + sd_json_variant *manifests; + } index_data = { + .schema_version = UINT_MAX, + }; + + static const struct sd_json_dispatch_field dispatch_table[] = { + { "schemaVersion", SD_JSON_VARIANT_INTEGER, sd_json_dispatch_uint, voffsetof(index_data, schema_version), SD_JSON_MANDATORY }, + { "mediaType", SD_JSON_VARIANT_STRING, sd_json_dispatch_const_string, voffsetof(index_data, media_type), 0 }, + { "manifests", SD_JSON_VARIANT_ARRAY, sd_json_dispatch_variant_noref, voffsetof(index_data, manifests), SD_JSON_MANDATORY }, + {} + }; + + r = sd_json_dispatch(v, dispatch_table, SD_JSON_ALLOW_EXTENSIONS|SD_JSON_LOG, &index_data); + if (r < 0) + return r; + + if (index_data.schema_version != 2) + return log_error_errno(SYNTHETIC_ERRNO(EBADMSG), "OCI image index has unsupported schema version %u, refusing.", index_data.schema_version); + if (index_data.media_type && !streq(index_data.media_type, "application/vnd.oci.image.index.v1+json")) + return log_error_errno(SYNTHETIC_ERRNO(EBADMSG), "OCI image index has unexpected media type '%s', refusing.", index_data.media_type); + + sd_json_variant *m; + JSON_VARIANT_ARRAY_FOREACH(m, index_data.manifests) { + _cleanup_(oci_index_entry_done) OciIndexEntry entry = { + .size = UINT64_MAX, + }; + + r = json_dispatch_oci_index_entry(m, &entry); + if (r < 0) + return r; + if (r == 0) /* skip? */ + continue; + + if (!oci_index_entry_match(&entry)) + continue; + + r = oci_pull_redirect_manifest(i, &entry); + if (r < 0) + return r; + + return 1; /* continue */ + } + + return log_error_errno(SYNTHETIC_ERRNO(ENOENT), "No suitable OCI image manifest found for local system."); +} + +DEFINE_PRIVATE_HASH_OPS_WITH_VALUE_DESTRUCTOR(pull_job_hash_ops, void, trivial_hash_func, trivial_compare_func, PullJob, pull_job_unref); + +static int oci_pull_job_on_open_disk(PullJob *j) { + int r; + + assert(j); + + OciLayerState *st = ASSERT_PTR(j->userdata); + OciPull *i = st->pull; + + if (!st->temp_path) { + r = tempfn_random_child(i->image_root, "oci", &st->temp_path); + if (r < 0) + return log_oom(); + } + + (void) mkdir_parents_label(st->temp_path, 0700); + + if (FLAGS_SET(i->flags, IMPORT_FOREIGN_UID)) { + r = import_make_foreign_userns(&i->userns_fd); + if (r < 0) + return r; + + _cleanup_close_ int directory_fd = -EBADF; + r = mountfsd_make_directory(st->temp_path, MODE_INVALID, /* flags= */ 0, &directory_fd); + if (r < 0) + return log_error_errno(r, "Failed to make directory via mountfsd: %m"); + + r = mountfsd_mount_directory_fd(directory_fd, i->userns_fd, DISSECT_IMAGE_FOREIGN_UID, &st->tree_fd); + if (r < 0) + return log_error_errno(r, "Failed to mount directory via mountsd: %m"); + } else { + if (i->flags & IMPORT_BTRFS_SUBVOL) + r = btrfs_subvol_make_fallback(AT_FDCWD, st->temp_path, 0755); + else + r = RET_NERRNO(mkdir(st->temp_path, 0755)); + if (r < 0) + return log_error_errno(r, "Failed to create directory/subvolume %s: %m", st->temp_path); + + if (r > 0 && (i->flags & IMPORT_BTRFS_QUOTA)) { /* actually btrfs subvol */ + (void) import_assign_pool_quota_and_warn(i->image_root); + (void) import_assign_pool_quota_and_warn(st->temp_path); + } + + st->tree_fd = open(st->temp_path, O_DIRECTORY|O_CLOEXEC|O_NOFOLLOW); + if (st->tree_fd < 0) + return log_error_errno(errno, "Failed to open '%s': %m", st->temp_path); + } + + j->disk_fd = import_fork_tar_x(st->tree_fd, i->userns_fd, &st->tar_pid); + if (j->disk_fd < 0) + return j->disk_fd; + + return 0; +} + +static void oci_layer_state_free_wrapper(void *p) { + oci_layer_state_free(p); +} + +typedef struct OciManifestLayer { + char *media_type; + struct iovec digest; + uint64_t size; +} OciManifestLayer; + +static void oci_manifest_layer_done(OciManifestLayer *layer) { + assert(layer); + + layer->media_type = mfree(layer->media_type); + iovec_done(&layer->digest); +} + +static int oci_layer_dirname_for_digest(struct iovec *iovec, char **ret) { + assert(iovec); + assert(ret); + + _cleanup_free_ char *h = oci_digest_string(iovec); + if (!h) + return log_oom(); + + _cleanup_free_ char *fn = strjoin(".oci-", h); + if (!fn) + return log_oom(); + + *ret = TAKE_PTR(fn); + return 0; +} + +static int oci_pull_queue_layer(OciPull *i, OciManifestLayer *layer) { + int r; + + assert(i); + assert(layer); + + _cleanup_free_ char *url = NULL; + r = oci_make_blob_url(i->protocol, i->repository, i->image, &layer->digest, &url); + if (r < 0) + return r; + + _cleanup_free_ char *fn = NULL; + r = oci_layer_dirname_for_digest(&layer->digest, &fn); + if (r < 0) + return r; + + _cleanup_free_ char *final_path = path_join(i->image_root, fn); + if (!final_path) + return log_oom(); + + r = is_dir(final_path, /* follow= */ true); + if (r < 0) { + if (r != -ENOENT) + return log_error_errno(r, "Failed to determine if directory '%s' exists: %m", final_path); + } else { + log_debug("Layer '%s' already exists, skipping download.", final_path); + return 0; + } + + _cleanup_(oci_layer_state_freep) OciLayerState *st = new(OciLayerState, 1); + if (!st) + return -ENOMEM; + + *st = (OciLayerState) { + .pull = i, + .tar_pid = PIDREF_NULL, + .tree_fd = -EBADF, + .final_path = TAKE_PTR(final_path), + }; + + /* Set up */ + _cleanup_(pull_job_unrefp) PullJob *j = NULL; + r = pull_job_new(&j, url, i->glue, st); + if (r < 0) + return r; + + j->free_userdata = oci_layer_state_free_wrapper; + TAKE_PTR(st); + + r = pull_job_set_accept(j, STRV_MAKE(layer->media_type)); + if (r < 0) + return r; + + if (i->bearer_token) { + r = pull_job_set_bearer_token(j, i->bearer_token); + if (r < 0) + return r; + } + + j->on_finished = oci_pull_job_on_finished_layer; + j->on_open_disk = oci_pull_job_on_open_disk; + j->expected_content_length = layer->size; + j->calc_checksum = true; + if (!iovec_memdup(&layer->digest, &j->expected_checksum)) + return -ENOMEM; + + r = ordered_set_ensure_put(&i->queued_layer_jobs, &pull_job_hash_ops, j); + if (r < 0) + return r; + + TAKE_PTR(j); + return 0; +} + +static int json_dispatch_oci_manifest_layer(sd_json_variant *v, OciManifestLayer *layer) { + int r; + + assert(v); + assert(layer); + + static const struct sd_json_dispatch_field dispatch_table[] = { + { "mediaType", SD_JSON_VARIANT_STRING, sd_json_dispatch_string, offsetof(OciManifestLayer, media_type), SD_JSON_MANDATORY }, + { "digest", SD_JSON_VARIANT_STRING, json_dispatch_oci_digest, offsetof(OciManifestLayer, digest), SD_JSON_MANDATORY }, + { "size", SD_JSON_VARIANT_UNSIGNED, sd_json_dispatch_uint64, offsetof(OciManifestLayer, size), SD_JSON_MANDATORY }, + {} + }; + + r = sd_json_dispatch(v, dispatch_table, SD_JSON_ALLOW_EXTENSIONS|SD_JSON_LOG, layer); + if (r < 0) + return r; + + if (!STR_IN_SET(layer->media_type, + "application/vnd.oci.image.layer.v1.tar", + "application/vnd.oci.image.layer.v1.tar+gzip", + "application/vnd.oci.image.layer.v1.tar+zstd")) { + json_log(v, SD_JSON_LOG, /* error= */ 0, "Unexpected manifest layer with media type '%s', skipping.", layer->media_type); + return 0; + } + + return 1; +} + +typedef struct OciManifestConfig { + char *media_type; + struct iovec digest; + uint64_t size; + struct iovec data; +} OciManifestConfig; + +static void oci_manifest_config_done(OciManifestConfig *config) { + assert(config); + + config->media_type = mfree(config->media_type); + iovec_done(&config->digest); + iovec_done(&config->data); +} + +static int oci_pull_fetch_config(OciPull *i, OciManifestConfig *config) { + int r; + + assert(i); + assert(config); + + if (i->config_job) + return log_error_errno(SYNTHETIC_ERRNO(EBUSY), "Two configs requested, refusing."); + + _cleanup_free_ char *url = NULL; + r = oci_make_blob_url(i->protocol, i->repository, i->image, &config->digest, &url); + if (r < 0) + return r; + + _cleanup_free_ char *h = oci_digest_string(&config->digest); + if (!h) + return log_oom(); + + r = pull_job_new(&i->config_job, url, i->glue, i); + if (r < 0) + return r; + + r = pull_job_set_accept(i->config_job, STRV_MAKE(config->media_type)); + if (r < 0) + return r; + + if (i->bearer_token) { + r = pull_job_set_bearer_token(i->config_job, i->bearer_token); + if (r < 0) + return r; + } + + i->config_job->on_finished = oci_pull_job_on_finished_config; + i->config_job->expected_content_length = config->size; + i->config_job->calc_checksum = true; + if (!iovec_memdup(&config->digest, &i->config_job->expected_checksum)) + return log_oom(); + + i->config_job->description = strjoin("Image configuration (", url, ")"); + + r = pull_job_begin(i->config_job); + if (r < 0) + return r; + + return 0; +} + +static int json_dispatch_oci_manifest_config(sd_json_variant *v, OciManifestConfig *config) { + int r; + + assert(v); + assert(config); + + static const struct sd_json_dispatch_field dispatch_table[] = { + { "mediaType", SD_JSON_VARIANT_STRING, sd_json_dispatch_string, offsetof(OciManifestConfig, media_type), SD_JSON_MANDATORY }, + { "digest", SD_JSON_VARIANT_STRING, json_dispatch_oci_digest, offsetof(OciManifestConfig, digest), SD_JSON_MANDATORY }, + { "size", SD_JSON_VARIANT_UNSIGNED, sd_json_dispatch_uint64, offsetof(OciManifestConfig, size), SD_JSON_MANDATORY }, + { "data", SD_JSON_VARIANT_STRING, json_dispatch_unbase64_iovec, offsetof(OciManifestConfig, data), 0 }, + {} + }; + + r = sd_json_dispatch(v, dispatch_table, SD_JSON_ALLOW_EXTENSIONS|SD_JSON_LOG, config); + if (r < 0) + return r; + + if (!STR_IN_SET(config->media_type, "application/vnd.oci.image.config.v1+json")) { + json_log(v, SD_JSON_LOG, /* error= */ 0, "Unexpected manifest config with media type '%s', skipping.", config->media_type); + return 0; + } + + if (iovec_is_set(&config->data)) { + if (config->data.iov_len != config->size) + return json_log(v, SD_JSON_LOG, SYNTHETIC_ERRNO(EBADMSG), "Manifest config size mismatch."); + + uint8_t h[SHA256_DIGEST_SIZE]; + if (memcmp_nn(sha256_direct(config->data.iov_base, config->data.iov_len, h), SHA256_DIGEST_SIZE, + config->digest.iov_base, config->digest.iov_len) != 0) + return json_log(v, SD_JSON_LOG, SYNTHETIC_ERRNO(EBADMSG), "Manifest data size mismatch."); + } + + return 1; +} + +static int oci_pull_fetch_layers(OciPull *i) { + int r; + + assert(i); + + while (set_size(i->active_layer_jobs) < ACTIVE_LAYERS_MAX) { + _cleanup_(pull_job_unrefp) PullJob *j = ordered_set_steal_first(i->queued_layer_jobs); + if (!j) + return 0; + + r = pull_job_begin(j); + if (r < 0) + return r; + + r = set_ensure_put(&i->active_layer_jobs, &pull_job_hash_ops, j); + if (r < 0) + return r; + + TAKE_PTR(j); + } + + return 0; +} + +static int oci_pull_process_manifest(OciPull *i, PullJob *j) { + int r; + + assert(i); + assert(j); + + /* Processes a just downloaded OCI image manifest, as per: + * + * https://github.com/opencontainers/image-spec/blob/main/manifest.md */ + + _cleanup_(sd_json_variant_unrefp) sd_json_variant *v = NULL; + r = pull_job_payload_as_json(j, &v); + if (r < 0) + return r; + + struct { + unsigned schema_version; + const char *media_type; + sd_json_variant *config; + sd_json_variant *layers; + } manifest_data = { + .schema_version = UINT_MAX, + }; + + static const struct sd_json_dispatch_field dispatch_table[] = { + { "schemaVersion", SD_JSON_VARIANT_INTEGER, sd_json_dispatch_uint, voffsetof(manifest_data, schema_version), SD_JSON_MANDATORY }, + { "mediaType", SD_JSON_VARIANT_STRING, sd_json_dispatch_const_string, voffsetof(manifest_data, media_type), 0 }, + { "config", SD_JSON_VARIANT_OBJECT, sd_json_dispatch_variant_noref, voffsetof(manifest_data, config), 0 }, + { "layers", SD_JSON_VARIANT_ARRAY, sd_json_dispatch_variant_noref, voffsetof(manifest_data, layers), SD_JSON_MANDATORY }, + {} + }; + + r = sd_json_dispatch(v, dispatch_table, SD_JSON_ALLOW_EXTENSIONS|SD_JSON_LOG, &manifest_data); + if (r < 0) + return r; + + if (manifest_data.schema_version != 2) + return log_error_errno(SYNTHETIC_ERRNO(EBADMSG), "OCI image manifest has unsupported schema version %u, refusing.", manifest_data.schema_version); + if (manifest_data.media_type && !streq(manifest_data.media_type, "application/vnd.oci.image.manifest.v1+json")) + return log_error_errno(SYNTHETIC_ERRNO(EBADMSG), "OCI image manifest has unexpected media type '%s', refusing.", manifest_data.media_type); + + if (manifest_data.config) { + _cleanup_(oci_manifest_config_done) OciManifestConfig config = { + .size = UINT64_MAX, + }; + + r = json_dispatch_oci_manifest_config(manifest_data.config, &config); + if (r < 0) + return r; + + if (iovec_is_set(&config.data)) { + iovec_done(&i->config); + i->config = TAKE_STRUCT(config.data); + } else { + r = oci_pull_fetch_config(i, &config); + if (r < 0) + return r; + } + } + + sd_json_variant *m; + JSON_VARIANT_ARRAY_FOREACH(m, manifest_data.layers) { + _cleanup_(oci_manifest_layer_done) OciManifestLayer layer = { + .size = UINT64_MAX, + }; + + r = json_dispatch_oci_manifest_layer(m, &layer); + if (r < 0) + return r; + if (r == 0) /* skip? */ + continue; + + if (i->n_layers >= LAYER_MAX) + return log_error_errno(SYNTHETIC_ERRNO(E2BIG), "Too many layers in manifest (> %zu), refusing.", i->n_layers); + + if (!GREEDY_REALLOC(i->layer_digests, i->n_layers + 1)) + return log_oom(); + + if (!iovec_memdup(&layer.digest, i->layer_digests + i->n_layers)) + return log_oom(); + + i->n_layers++; + + r = oci_pull_queue_layer(i, &layer); + if (r < 0) + return r; + } + + if (i->n_layers == 0) + return log_error_errno(SYNTHETIC_ERRNO(EPROTO), "Manifest has no recognized file system layers, refusing."); + + assert(i->n_layers >= ordered_set_size(i->queued_layer_jobs)); + size_t present_layers = i->n_layers - ordered_set_size(i->queued_layer_jobs); + + log_info("Image %s/%s:%s has %zu layers, %zu already present.", i->repository, i->image, i->tag, i->n_layers, present_layers); + + /* Assign nice descriptions that indicate which layer we are talking of here */ + PullJob *q; + size_t k = 0; + ORDERED_SET_FOREACH(q, i->queued_layer_jobs) { + _cleanup_free_ char *d = NULL; + + if (asprintf(&d, "Layer %zu/%zu (%s)", present_layers + k + 1, i->n_layers, q->url) < 0) + return log_oom(); + + free_and_replace(q->description, d); + k++; + } + + return oci_pull_work(i); +} + +static bool oci_pull_is_done(OciPull *i) { + assert(i); + assert(i->manifest_job); + + if (!PULL_JOB_IS_COMPLETE(i->manifest_job) || + (i->bearer_token_job && !PULL_JOB_IS_COMPLETE(i->bearer_token_job)) || + (i->config_job && !PULL_JOB_IS_COMPLETE(i->config_job))) + return false; + + return ordered_set_isempty(i->queued_layer_jobs) && + set_isempty(i->active_layer_jobs); +} + +typedef struct OciConfiguration { + char *user; + char **env; + char **entrypoint; + char **cmd; + char *working_dir; + int stop_signal; +} OciConfiguration; + +static void oci_configuration_done(OciConfiguration *c) { + assert(c); + + c->user = mfree(c->user); + c->env = strv_free(c->env); + c->entrypoint = strv_free(c->entrypoint); + c->cmd = strv_free(c->cmd); + c->working_dir = mfree(c->working_dir); +} + +static int print_pair_escaped(FILE *f, const char *field, const char *value) { + assert(f); + assert(field); + assert(value); + + _cleanup_free_ char *escaped = cescape(value); + if (!escaped) + return log_oom(); + + fputs(field, f); + fputc('=', f); + fputs(escaped, f); + fputc('\n', f); + + return 0; +} + +static int dispatch_unsupported(const char *name, sd_json_variant *v, sd_json_dispatch_flags_t flags, void *userdata) { + assert(v); + json_log(v, flags|SD_JSON_WARNING, /* error= */ 0, "OCI image specification field '%s' cannot be be translated to any corresponding .nspawn setting, skipping.", name); + return 0; +} + +static int oci_pull_save_nspawn_settings(OciPull *i) { + int r; + + assert(i); + assert(iovec_is_set(&i->config)); + + /* This translates the image spec "configuration" object into native .nspawn files for consumption by + * systemd-nspawn. It's vaguely related to + * https://github.com/opencontainers/image-spec/blob/v1.1.1/conversion.md except of course we don't + * translate to the OCI runtime spec here but rather to .nspawn knobs. */ + + /* The PullJob logic implicitly NUL terminates */ + assert(((char*) i->config.iov_base)[i->config.iov_len] == 0); + + if (memchr(i->config.iov_base, 0, i->config.iov_len)) + return log_error_errno(SYNTHETIC_ERRNO(EBADMSG), "Embedded NUL by in JSON config data, refusing."); + + _cleanup_(sd_json_variant_unrefp) sd_json_variant *v = NULL; + unsigned line = 0, column = 0; + r = sd_json_parse((char*) i->config.iov_base, /* flags= */ 0, &v, &line, &column); + if (r < 0) + return log_error_errno(r, "Failed to parse JSON config data at position %u:%u: %m", line, column); + + sd_json_variant *config = sd_json_variant_by_key(v, "config"); + + _cleanup_(oci_configuration_done) OciConfiguration config_data = { + .stop_signal = SIGTERM, + }; + static const struct sd_json_dispatch_field dispatch_table[] = { + { "User", SD_JSON_VARIANT_STRING, json_dispatch_user_group_name, offsetof(OciConfiguration, user), 0 }, + { "Env", SD_JSON_VARIANT_ARRAY, json_dispatch_strv_environment, offsetof(OciConfiguration, env), 0 }, + { "Entrypoint", SD_JSON_VARIANT_ARRAY, sd_json_dispatch_strv, offsetof(OciConfiguration, entrypoint), 0 }, + { "Cmd", SD_JSON_VARIANT_ARRAY, sd_json_dispatch_strv, offsetof(OciConfiguration, cmd), 0 }, + { "WorkingDir", SD_JSON_VARIANT_STRING, json_dispatch_path, offsetof(OciConfiguration, working_dir), 0 }, + { "StopSignal", SD_JSON_VARIANT_STRING, sd_json_dispatch_signal, offsetof(OciConfiguration, stop_signal), 0 }, + + /* Currently unsupported fields that the OCI image spec defines but we cannot convert into + * .nspawn settings just yet. */ + { "ExposedPorts", _SD_JSON_VARIANT_TYPE_INVALID, dispatch_unsupported, 0, 0 }, + { "Volumes", _SD_JSON_VARIANT_TYPE_INVALID, dispatch_unsupported, 0, 0 }, + { "Labels", _SD_JSON_VARIANT_TYPE_INVALID, dispatch_unsupported, 0, 0 }, + { "ArgsEscaped", _SD_JSON_VARIANT_TYPE_INVALID, dispatch_unsupported, 0, 0 }, + { "Memory", _SD_JSON_VARIANT_TYPE_INVALID, dispatch_unsupported, 0, 0 }, + { "MemorySwap", _SD_JSON_VARIANT_TYPE_INVALID, dispatch_unsupported, 0, 0 }, + { "CpuShares", _SD_JSON_VARIANT_TYPE_INVALID, dispatch_unsupported, 0, 0 }, + { "Healthcheck", _SD_JSON_VARIANT_TYPE_INVALID, dispatch_unsupported, 0, 0 }, + {} + }; + + r = sd_json_dispatch(config, dispatch_table, SD_JSON_ALLOW_EXTENSIONS|SD_JSON_LOG, &config_data); + if (r < 0) + return r; + + _cleanup_free_ char *fn = strjoin(i->local, ".nspawn"); + if (!fn) + return log_oom(); + + _cleanup_free_ char *j = path_join(i->image_root, fn); + if (!fn) + return log_oom(); + + _cleanup_fclose_ FILE *f = NULL; + _cleanup_(unlink_and_freep) char *tmpfile = NULL; + r = fopen_tmpfile_linkable(j, O_WRONLY|O_CLOEXEC, &tmpfile, &f); + if (r < 0) + return log_error_errno(r, "Failed to create '%s': %m", j); + + fprintf(f, + "# Generated from OCI configuration object\n" + "[Exec]\n" + "Boot=no\n" + "KillSignal=%s\n", + signal_to_string(config_data.stop_signal)); + + if (config_data.user && !STR_IN_SET(config_data.user, "root", "0")) { + r = print_pair_escaped(f, "User", config_data.user); + if (r < 0) + return r; + } + + if (config_data.working_dir && !path_equal(config_data.working_dir, "/")) { + r = print_pair_escaped(f, "WorkingDirectory", config_data.working_dir); + if (r < 0) + return r; + } + + STRV_FOREACH(e, config_data.env) { + r = print_pair_escaped(f, "Environment", *e); + if (r < 0) + return r; + } + + _cleanup_strv_free_ char **cmdline = strv_copy(config_data.entrypoint); + if (!cmdline) + return log_oom(); + if (strv_extend_strv(&cmdline, config_data.cmd, /* filter_duplicates= */ false) < 0) + return log_oom(); + + if (!strv_isempty(cmdline)) { + _cleanup_free_ char *ej = NULL; + + STRV_FOREACH(z, cmdline) { + _cleanup_free_ char *q = NULL; + const char *a; + + if (isempty(*z)) + a = "\"\""; + else if (!in_charset(*z, ALPHANUMERICAL "=+-_/;.:,;")) { + _cleanup_free_ char *e = shell_escape(*z, "\""); + if (!e) + return log_oom(); + + q = strjoin("\"", e, "\""); + if (!q) + return log_oom(); + + a = q; + } else + a = *z; + + if (!strextend_with_separator(&ej, " ", a)) + return log_oom(); + } + + fprintf(f, "Parameters=%s\n", ej); + } + + r = flink_tmpfile(f, tmpfile, j, LINK_TMPFILE_REPLACE); + if (r < 0) + return log_error_errno(r, "Failed to move '%s' into place: %m", j); + + log_info("systemd-nspawn settings file written to '%s'.", j); + return 0; +} + +static int oci_pull_save_oci_config(OciPull *i) { + int r; + + assert(i); + assert(iovec_is_set(&i->config)); + + _cleanup_free_ char *fn = strjoin(i->local, ".oci-config"); + if (!fn) + return log_oom(); + + _cleanup_free_ char *j = path_join(i->image_root, fn); + if (!fn) + return log_oom(); + + _cleanup_close_ int fd = -EBADF; + _cleanup_(unlink_and_freep) char *tmpfile = NULL; + fd = open_tmpfile_linkable(j, O_WRONLY|O_CLOEXEC, &tmpfile); + if (fd < 0) + return log_error_errno(fd, "Failed to create '%s': %m", j); + + r = loop_write(fd, i->config.iov_base, i->config.iov_len); + if (r < 0) + return log_error_errno(r, "Failed to write '%s': %m", j); + + r = link_tmpfile(fd, tmpfile, j, LINK_TMPFILE_REPLACE); + if (r < 0) + return log_error_errno(r, "Failed to move '%s' into place: %m", j); + + log_info("OCI config written to '%s'.", j); + return 0; +} + +static int oci_pull_save_mstack(OciPull *i) { + int r; + + assert(i); + + _cleanup_free_ char *dn = strjoin(i->local, ".mstack"); + if (!dn) + return log_oom(); + + _cleanup_free_ char *j = path_join(i->image_root, dn); + if (!dn) + return log_oom(); + + log_notice("Creating '%s'", j); + + _cleanup_free_ char *_jt = NULL; + r = tempfn_random(j, "oci", &_jt); + if (r < 0) + return log_oom(); + + _cleanup_close_ int dir_fd = xopenat(AT_FDCWD, _jt, O_DIRECTORY|O_CREAT|O_CLOEXEC); + if (dir_fd < 0) + return log_error_errno(dir_fd, "Failed to create '%s': %m", j); + + _cleanup_(rm_rf_physical_and_freep) char *jt = TAKE_PTR(_jt); + for (size_t k = 0; k < i->n_layers; k++) { + _cleanup_free_ char *sl = NULL; + if (asprintf(&sl, "layer@%zu", k) < 0) + return log_oom(); + + _cleanup_free_ char *ldn = NULL; + if (oci_layer_dirname_for_digest(i->layer_digests + k, &ldn) < 0) + return log_oom(); + + _cleanup_free_ char *with_dot_dot = path_join("..", ldn); + if (!with_dot_dot) + return log_oom(); + + if (symlinkat(with_dot_dot, dir_fd, sl) < 0) + return log_error_errno(errno, "Failed to make symlink for layer '%s': %m", sl); + } + + if (!FLAGS_SET(i->flags, IMPORT_READ_ONLY)) { + /* If this shall be a mutable container let's also create an "rw" layer, that will become + * the upper layer in the overlayfs stack. */ + + if (FLAGS_SET(i->flags, IMPORT_FOREIGN_UID)) { + r = import_make_foreign_userns(&i->userns_fd); + if (r < 0) + return r; + + r = mountfsd_make_directory_fd( + dir_fd, + "rw", + 0755, + /* flags= */ 0, + /* ret_directory_fd= */ NULL); + if (r < 0) + return r; + } else { + _cleanup_close_ int rw_fd = open_mkdir_at(dir_fd, "rw", O_EXCL|O_CLOEXEC, 0755); + if (rw_fd < 0) + return log_error_errno(rw_fd, "Failed to create 'rw' layer: %m"); + } + } + + if (rename(jt, j) < 0) + return log_error_errno(errno, "Failed to move '%s' into place: %m", j); + + jt = mfree(jt); /* Disarm rm_rf_physical_and_free() */ + + log_info("Mount stack written to '%s'.", j); + return 0; +} + +static int oci_pull_make_local(OciPull *i) { + int r; + + assert(i); + assert(oci_pull_is_done(i)); + + r = oci_pull_save_mstack(i); + if (r < 0) + return r; + + if (!iovec_is_set(&i->config) || + iovec_memcmp(&i->config, &IOVEC_MAKE_STRING("{}")) == 0) + log_info("Image has no configuration, not saving."); + else { + r = oci_pull_save_nspawn_settings(i); + if (r < 0) + return r; + + r = oci_pull_save_oci_config(i); + if (r < 0) + return r; + } + + return 0; +} + +static int oci_pull_work(OciPull *i) { + int r; + + assert(i); + + r = oci_pull_fetch_layers(i); + if (r < 0) + return r; + + if (!oci_pull_is_done(i)) + return 1; /* continue */ + + r = oci_pull_make_local(i); + if (r < 0) + return r; + + log_info("Everything done."); + return 0; /* done */ +} + +static void oci_pull_finish(OciPull *i, int r) { + assert(i); + + if (i->on_finished) + i->on_finished(i, r, i->userdata); + else + sd_event_exit(i->event, r); +} + +static void oci_pull_job_on_finished_layer(PullJob *j) { + int r; + + assert(j); + + OciLayerState *st = ASSERT_PTR(j->userdata); + OciPull *i = st->pull; + + if (j->error != 0) { + r = log_error_errno(j->error, "Failed to retrieve layer file: %m"); + goto finish; + } + + pull_job_close_disk_fd(j); + + if (pidref_is_set(&st->tar_pid)) { + r = pidref_wait_for_terminate_and_check("tar", &st->tar_pid, WAIT_LOG); + if (r < 0) + goto finish; + + pidref_done(&st->tar_pid); + if (r != EXIT_SUCCESS) { + r = -EIO; + goto finish; + } + } + + assert(st->temp_path); + assert(st->final_path); + + r = install_file(AT_FDCWD, st->temp_path, + AT_FDCWD, st->final_path, + INSTALL_READ_ONLY|INSTALL_GRACEFUL); + if (r < 0) { + log_error_errno(r, "Failed to rename final image name to %s: %m", st->final_path); + goto finish; + } + + st->temp_path = mfree(st->temp_path); + + r = set_ensure_put(&i->done_layer_jobs, &pull_job_hash_ops, j); + if (r < 0) { + log_oom(); + goto finish; + } + + assert(set_remove(i->active_layer_jobs, j) == j); + + r = oci_pull_work(i); + if (r <= 0) + goto finish; + + return; + +finish: + oci_pull_finish(i, r); +} + +static void oci_pull_job_on_finished_config(PullJob *j) { + int r; + + assert(j); + OciPull *i = ASSERT_PTR(j->userdata); + assert(i->config_job == j); + + if (j->error != 0) { + if (j->error == -ENOMEDIUM) /* HTTP 404 */ + r = log_error_errno(j->error, "Failed to retrieve config object. (Wrong URL?)"); + else + r = log_error_errno(j->error, "Failed to retrieve config object: %m"); + goto finish; + } + + iovec_done(&i->config); + i->config = TAKE_STRUCT(j->payload); + + r = oci_pull_work(i); + if (r <= 0) + goto finish; + + return; + +finish: + oci_pull_finish(i, r); +} + +static void oci_pull_job_on_finished_bearer_token(PullJob *j) { + int r; + + assert(j); + OciPull *i = ASSERT_PTR(j->userdata); + assert(i->bearer_token_job == j); + + _cleanup_(sd_json_variant_unrefp) sd_json_variant *v = NULL; + + if (j->error != 0) { + if (j->error == -ENOMEDIUM) /* HTTP 404 */ + r = log_error_errno(j->error, "Failed to retrieve bearer token. (Wrong URL?)"); + else + r = log_error_errno(j->error, "Failed to retrieve bearer token: %m"); + goto finish; + } + + r = pull_job_payload_as_json(j, &v); + if (r < 0) + goto finish; + + sd_json_variant *tv = sd_json_variant_by_key(v, "token"); + if (!tv) { + r = log_error_errno(SYNTHETIC_ERRNO(EBADMSG), "Bearer token lacks 'token' field."); + goto finish; + } + + if (!sd_json_variant_is_string(tv)) { + r = log_error_errno(SYNTHETIC_ERRNO(EBADMSG), "'token' field of bearer token is not a string."); + goto finish; + } + + r = free_and_strdup_warn(&i->bearer_token, sd_json_variant_string(tv)); + if (r < 0) + goto finish; + + assert(i->manifest_job); + r = pull_job_set_bearer_token(i->manifest_job, i->bearer_token); + if (r < 0) { + log_error_errno(r, "Failed to set bearer token on manifest job: %m"); + goto finish; + } + + r = pull_job_restart(i->manifest_job, /* new_url= */ NULL); + if (r < 0) + goto finish; + + return; + +finish: + oci_pull_finish(i, r); +} + +static int make_bearer_token_url(const char *realm, const char *service, const char *scope, char **ret) { + assert(realm); + assert(service); + assert(scope); + assert(ret); + + _cleanup_free_ char *rs = urlescape(service); + if (!rs) + return -ENOMEM; + + _cleanup_free_ char *ss = urlescape(scope); + if (!ss) + return -ENOMEM; + + _cleanup_free_ char *url = strjoin(realm, "?service=", rs, "&scope=", ss); + if (!url) + return -ENOMEM; + + *ret = TAKE_PTR(url); + return 0; +} + +static int oci_pull_process_authentication_challenge(OciPull *i, const char *challenge) { + int r; + + assert(i); + assert(challenge); + + /* We only know what to do with the bearer token challenge */ + const char *e = startswith_no_case(challenge, "bearer "); + if (!e) + return log_error_errno(SYNTHETIC_ERRNO(ENOKEY), "Authentication mechanism not recognized, cannot authenticate."); + + if (i->bearer_token_job) + return log_error_errno(SYNTHETIC_ERRNO(EBUSY), "Two bearer token challenges requested, refusing."); + + e += strspn(e, WHITESPACE); + + _cleanup_strv_free_ char **l = NULL; + r = strv_split_full(&l, e, ",", EXTRACT_KEEP_QUOTE); + if (r < 0) + return log_error_errno(r, "Failed to split bearer token paramaters: %m"); + + _cleanup_free_ char *realm = NULL, *scope = NULL, *service = NULL; + struct { + const char *name; + char **value; + } fields[] = { + { "realm=\"", &realm }, + { "scope=\"", &scope }, + { "service=\"", &service }, + }; + + STRV_FOREACH(k, l) { + bool found = false; + + FOREACH_ELEMENT(f, fields) { + const char *v = startswith_no_case(*k, f->name); + if (!v) + continue; + + const char *q = endswith(v,"\""); + if (!q) { + log_warning("Field isn't quoted properly: %s", *k); + continue; + } + + _cleanup_free_ char *s = strndup(v, q - v); + if (!s) + return log_oom(); + + free_and_replace(*f->value, s); + found = true; + break; + } + + if (!found) + log_debug("Ignoring bearer token challenge field: %s", *k); + } + + if (!realm || !service || !scope) + return log_error_errno(SYNTHETIC_ERRNO(ENOKEY), "Incomplete bearer token fields"); + + if (!startswith_no_case(realm, "https://")) + return log_error_errno(SYNTHETIC_ERRNO(ENOKEY), "Authentication realm is not an URL, don't know how to proceed."); + + _cleanup_free_ char *url = NULL; + r = make_bearer_token_url(realm, service, scope, &url); + if (r < 0) + return log_error_errno(r, "Failed to make bearer token URL: %m"); + + r = pull_job_new(&i->bearer_token_job, url, i->glue, i); + if (r < 0) + return r; + + i->bearer_token_job->on_finished = oci_pull_job_on_finished_bearer_token; + i->bearer_token_job->description = strjoin("Bearer token (", url, ")"); + + r = pull_job_begin(i->bearer_token_job); + if (r < 0) + return r; + + return 1; /* continue running */ +} + +static void oci_pull_job_on_finished_manifest(PullJob *j) { + int r; + + assert(j); + OciPull *i = ASSERT_PTR(j->userdata); + assert(i->manifest_job == j); + + if (j->error != 0) { + if (j->error == -ENOMEDIUM) /* HTTP 404 */ + r = log_error_errno(j->error, "Failed to retrieve manifest/index file. (Wrong URL?)"); + else if (j->error == -ENOKEY) { /* HTTP 401 - Need authentication */ + + if (j->authentication_challenge) { + r = oci_pull_process_authentication_challenge(i, j->authentication_challenge); + if (r > 0) + return; + + goto finish; + } else + r = log_error_errno(j->error, "Failed to retrieve manifest/index file. (Needing authentication.)"); + } else + r = log_error_errno(j->error, "Failed to retrieve manifest/index file: %m"); + goto finish; + } + + /* If we have no content type, let's assume it's a manifest, not an index. This can happen in case we + * operate with file:// URLs, which in turn is really useful for testing purposes, to mock an OCI + * registry without having to set up an HTTP service. */ + if (streq_ptr(j->content_type, "application/vnd.oci.image.manifest.v1+json") || + !j->content_type) { + r = oci_pull_process_manifest(i, j); + if (r <= 0) + goto finish; + + return; + } + + if (streq_ptr(j->content_type, "application/vnd.oci.image.index.v1+json")) { + + if (i->refuse_index) { + r = log_error_errno(SYNTHETIC_ERRNO(EPROTO), "Already processed an OCI index, refusing to process another one."); + goto finish; + } + + /* For now do not allow nested indexes */ + i->refuse_index = true; + + r = oci_pull_process_index(i, j); + if (r <= 0) + goto finish; + + return; + } + + r = log_error_errno(SYNTHETIC_ERRNO(EPROTO), "Unexpected content type '%s', refusing.", strna(j->content_type)); + +finish: + oci_pull_finish(i, r); +} + +int oci_pull_start( + OciPull *i, + const char *ref, + const char *local, + ImportFlags flags) { + + int r; + + assert(i); + assert(ref); + + r = oci_ref_parse(ref, &i->repository, &i->image, &i->tag); + if (r < 0) + return r; + + r = oci_ref_normalize(&i->protocol, &i->repository, &i->image, &i->tag); + if (r < 0) + return r; + + if (local && !pull_validate_local(local, flags)) + return -EINVAL; + + if (i->manifest_job) + return -EBUSY; + + r = free_and_strdup(&i->local, local); + if (r < 0) + return r; + + i->flags = flags; + + _cleanup_free_ char *url = NULL; + r = oci_make_manifest_url(i->protocol, i->repository, i->image, i->tag, &url); + if (r < 0) + return r; + + /* Set up */ + r = pull_job_new(&i->manifest_job, url, i->glue, i); + if (r < 0) + return r; + + if (i->bearer_token) { + r = pull_job_set_bearer_token(i->manifest_job, i->bearer_token); + if (r < 0) + return r; + } + + r = pull_job_set_accept( + i->manifest_job, + STRV_MAKE("application/vnd.oci.image.manifest.v1+json", + "application/vnd.oci.image.index.v1+json")); + if (r < 0) + return r; + + i->manifest_job->on_finished = oci_pull_job_on_finished_manifest; + i->manifest_job->description = strjoin("Image Index (", url, ")"); + if (!i->manifest_job->description) + return -ENOMEM; + + return pull_job_begin(i->manifest_job); +} diff --git a/src/import/pull-oci.h b/src/import/pull-oci.h new file mode 100644 index 00000000000..86a37c33ec2 --- /dev/null +++ b/src/import/pull-oci.h @@ -0,0 +1,16 @@ +/* SPDX-License-Identifier: LGPL-2.1-or-later */ +#pragma once + +#include "shared-forward.h" +#include "import-common.h" + +typedef struct OciPull OciPull; + +typedef void (*OciPullFinished)(OciPull *pull, int error, void *userdata); + +int oci_pull_new(OciPull **ret, sd_event *event, const char *image_root, OciPullFinished on_finished, void *userdata); +OciPull* oci_pull_unref(OciPull *i); + +DEFINE_TRIVIAL_CLEANUP_FUNC(OciPull*, oci_pull_unref); + +int oci_pull_start(OciPull *i, const char *ref, const char *local, ImportFlags flags); diff --git a/src/import/pull.c b/src/import/pull.c index a57c1c039d9..e2f81615269 100644 --- a/src/import/pull.c +++ b/src/import/pull.c @@ -18,9 +18,11 @@ #include "iovec-util.h" #include "log.h" #include "main-func.h" +#include "oci-util.h" #include "parse-argument.h" #include "parse-util.h" #include "path-util.h" +#include "pull-oci.h" #include "pull-raw.h" #include "pull-tar.h" #include "runtime-scope.h" @@ -244,6 +246,71 @@ static int pull_raw(int argc, char *argv[], void *userdata) { return -r; } +static void on_oci_finished(OciPull *pull, int error, void *userdata) { + sd_event *event = userdata; + assert(pull); + + if (error == 0) + log_info("Operation completed successfully."); + + sd_event_exit(event, ABS(error)); +} + +static int pull_oci(int argc, char *argv[], void *userdata) { + int r; + + const char *ref = argv[1]; + + _cleanup_free_ char *image = NULL; + r = oci_ref_parse(ref, /* ret_registry= */ NULL, &image, /* ret_tag= */ NULL); + if (r == -EINVAL) + return log_error_errno(r, "OCI ref '%s' is invalid.", ref); + if (r < 0) + return log_error_errno(r, "Failed to check of OCI ref '%s' is valid: %m", ref); + + _cleanup_free_ char *l = NULL; + const char *local; + if (argc >= 3) + local = empty_or_dash_to_null(argv[2]); + else { + r = path_extract_filename(image, &l); + if (r < 0) + return log_error_errno(r, "Failed to get extract final component of '%s': %m", image); + + local = l; + } + + _cleanup_free_ char *normalized = NULL; + r = normalize_local(local, ref, &normalized); + if (r < 0) + return r; + + _cleanup_(sd_event_unrefp) sd_event *event = NULL; + r = import_allocate_event_with_signals(&event); + if (r < 0) + return r; + + _cleanup_(oci_pull_unrefp) OciPull *pull = NULL; + r = oci_pull_new(&pull, event, arg_image_root, on_oci_finished, event); + if (r < 0) + return log_error_errno(r, "Failed to allocate puller: %m"); + + r = oci_pull_start( + pull, + ref, + normalized, + arg_import_flags & IMPORT_PULL_FLAGS_MASK_OCI); + if (r < 0) + return log_error_errno(r, "Failed to pull image: %m"); + + r = sd_event_loop(event); + if (r < 0) + return log_error_errno(r, "Failed to run event loop: %m"); + + log_info("Exiting."); + return -r; +} + static int help(int argc, char *argv[], void *userdata) { printf("%1$s [OPTIONS...] {COMMAND} ...\n" @@ -251,6 +318,7 @@ static int help(int argc, char *argv[], void *userdata) { "\n%2$sCommands:%3$s\n" " tar URL [NAME] Download a TAR image\n" " raw URL [NAME] Download a RAW image\n" + " oci REF [NAME] Download an OCI image\n" "\n%2$sOptions:%3$s\n" " -h --help Show this help\n" " --version Show package version\n" @@ -595,6 +663,7 @@ static int pull_main(int argc, char *argv[]) { { "help", VERB_ANY, VERB_ANY, 0, help }, { "tar", 2, 3, 0, pull_tar }, { "raw", 2, 3, 0, pull_raw }, + { "oci", 2, 3, 0, pull_oci }, {} }; diff --git a/src/import/test-oci-util.c b/src/import/test-oci-util.c new file mode 100644 index 00000000000..395b622b428 --- /dev/null +++ b/src/import/test-oci-util.c @@ -0,0 +1,22 @@ +/* SPDX-License-Identifier: LGPL-2.1-or-later */ + +#include "tests.h" +#include "oci-util.h" + +static void test_urlescape_one(const char *s, const char *expected) { + _cleanup_free_ char *t = ASSERT_PTR(urlescape(s)); + + ASSERT_STREQ(t, expected); +} + +TEST(urlescape) { + test_urlescape_one(NULL, ""); + test_urlescape_one("", ""); + test_urlescape_one("a", "a"); + test_urlescape_one(" ", "%20"); + test_urlescape_one(" ", "%20%20%20%20%20"); + test_urlescape_one("foo\tfoo\aqux", "foo%09foo%07qux"); + test_urlescape_one("müffel", "m%c3%bcffel"); +} + +DEFINE_TEST_MAIN(LOG_DEBUG);