]> git.ipfire.org Git - thirdparty/systemd.git/commitdiff
pull: add OCI support
authorLennart Poettering <lennart@amutable.com>
Fri, 7 Nov 2025 07:35:59 +0000 (08:35 +0100)
committerLennart Poettering <lennart@amutable.com>
Thu, 19 Feb 2026 14:05:15 +0000 (15:05 +0100)
meson_options.txt
src/import/import-common.h
src/import/meson.build
src/import/oci-registry/registry.docker.io.oci-registry [new file with mode: 0644]
src/import/oci-registry/registry.fedora.oci-registry [new file with mode: 0644]
src/import/oci-util.c [new file with mode: 0644]
src/import/oci-util.h [new file with mode: 0644]
src/import/pull-oci.c [new file with mode: 0644]
src/import/pull-oci.h [new file with mode: 0644]
src/import/pull.c
src/import/test-oci-util.c [new file with mode: 0644]

index 4d13d2866d29d704ec8cfa7d56a8a40ca2fe08af..c1af7ce2374928010ea673f0b805316fb8491007 100644 (file)
@@ -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')
index b568906a8a6c6094eb6d6a5ce116c77d2f3c6b71..6b10f8c29db87689ee20ea89d1ac9bf109617a01 100644 (file)
@@ -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;
index 46333fb15fe73f3a664c8ba950f931f401492be9..7735db7ff5efe2f87fdc97aba62832e6b9cb0c25 100644 (file)
@@ -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 (file)
index 0000000..a36b54e
--- /dev/null
@@ -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 (file)
index 0000000..e416bf2
--- /dev/null
@@ -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 (file)
index 0000000..37a617b
--- /dev/null
@@ -0,0 +1,401 @@
+/* SPDX-License-Identifier: LGPL-2.1-or-later */
+
+#include <string.h>
+
+#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 <registry>/<name>:<tag>. 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.<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.<name>.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 (file)
index 0000000..72b6370
--- /dev/null
@@ -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 (file)
index 0000000..d55bd43
--- /dev/null
@@ -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 (file)
index 0000000..86a37c3
--- /dev/null
@@ -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);
index a57c1c039d916910078908a258985e3900cdbe48..e2f816152698f689d8e0fd8c5dcf5d7edce2d9b5 100644 (file)
 #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 (file)
index 0000000..395b622
--- /dev/null
@@ -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);