From 94ae5b36a2dc746a7ac7264c5c4a1498a99104fb Mon Sep 17 00:00:00 2001 From: Viorel Munteanu Date: Thu, 20 Aug 2026 15:06:32 +0000 Subject: [PATCH] Add -l/--list-images to discover OCI archives in a directory list_oci_images() (src/oci_image.cpp) scans a directory non-recursively for *.tar/*.tar.* files and, for each one that's a valid OCI Image Layout archive, derives a name:tag from its index.json manifest annotations -- io.containerd.image.name if present (a full reference), else org.opencontainers.image.ref.name (conventionally just a bare tag for skopeo/podman-produced archives). Falls back to the archive's filename (.tar and any compression suffix stripped) for the name and "latest" for the tag. Files that aren't OCI archives are skipped quietly, since a directory scan is expected to hit unrelated tars. -l/--list-images wires this into the CLI, printing "name:tagfilename" per image. This is also why --log-level lost its short form last session: -l needed to be free for this. Co-Authored-By: Claude Sonnet 5 --- CLAUDE.md | 11 +++-- src/main.cpp | 42 +++++++++++++--- src/oci_image.cpp | 121 ++++++++++++++++++++++++++++++++++++++++++++++ src/oci_image.h | 15 ++++++ 4 files changed, 178 insertions(+), 11 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3b996f5..4e22ae9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,9 +16,13 @@ this repo as still early-stage. Source layout (all under `src/`): - `main.cpp` — CLI entry point, dependency checks, orchestration (`mount_image()`, - `run_container()`, `cleanup_image()`, `unmount_image()`). + `run_container()`, `cleanup_image()`, `unmount_image()`, `list_images_command()`). - `oci_image.{h,cpp}` — validates/parses the OCI Image Layout tar (libarchive + - nlohmann_json) and extracts layer blobs. + nlohmann_json) and extracts layer blobs. `list_oci_images()` scans a directory + (non-recursively) for `*.tar`/`*.tar.*` files and, for each valid OCI archive, + derives an image name/tag from its `index.json` manifest annotations + (`io.containerd.image.name` preferred, else `org.opencontainers.image.ref.name`), + falling back to the archive's filename and `"latest"` respectively. - `containers_storage.{h,cpp}` — wraps the `containers-storage` CLI (`import-layer`, `mount`, `unmount`, `layer --json`, `delete-layer`), forcing `fuse-overlayfs` as the overlay `mount_program`. `cleanup_layer_chain()` walks a layer's parent chain @@ -60,7 +64,8 @@ Build directory is `buildDir/` (already configured). - Build: `meson compile -C buildDir` (or `ninja -C buildDir`) - Run the executable: `./buildDir/slocker_lite -m ` (see `--help` for the full flag list: `-m/--mount`, `-r/--run`, `-u/--umount`, `-c/--cleanup`, - `-n/--no-nsenter`, `-t/--test`, `-l/--log-level`, `-h/--help`, `-V/--version`) + `-l/--list-images`, `-n/--no-nsenter`, `-t/--test`, `--log-level`, `-h/--help`, + `-V/--version`) - Run tests: `meson test -C buildDir` ## Code style diff --git a/src/main.cpp b/src/main.cpp index 4e0e42f..6bb97a6 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -38,18 +38,23 @@ namespace { constexpr std::array kRequiredTools = {"containers-storage", "bwrap"}; -enum class Mode { kNone, kMount, kUnmount, kTest, kRun, kCleanup }; +enum class Mode { kNone, kMount, kUnmount, kTest, kRun, kCleanup, kListImages }; -constexpr std::array kLongOptions = {{ +// --log-level has no short form (freed up so -l could become --list-images), so it +// needs a long-option val outside the printable-char range short options use. +constexpr int kLogLevelOpt = 256; + +constexpr std::array kLongOptions = {{ {"help", no_argument, nullptr, 'h'}, {"version", no_argument, nullptr, 'V'}, {"test", no_argument, nullptr, 't'}, - {"log-level", required_argument, nullptr, 'l'}, + {"log-level", required_argument, nullptr, kLogLevelOpt}, {"mount", required_argument, nullptr, 'm'}, {"umount", required_argument, nullptr, 'u'}, {"run", required_argument, nullptr, 'r'}, {"cleanup", required_argument, nullptr, 'c'}, {"no-nsenter", no_argument, nullptr, 'n'}, + {"list-images", required_argument, nullptr, 'l'}, {nullptr, 0, nullptr, 0}, }}; @@ -59,6 +64,7 @@ void print_usage(const char* prog) { " {0} -r|--run [-- [args...]]\n" " {0} -u|--umount \n" " {0} -c|--cleanup \n" + " {0} -l|--list-images \n" " {0} -t|--test\n" " {0} -h|--help\n" " {0} -V|--version\n" @@ -79,8 +85,10 @@ void print_usage(const char* prog) { " (this is automatic when running as root, where\n" " the mount is already directly visible; pass\n" " this to force it off otherwise)\n" + " -l, --list-images list OCI Image Layout tars (*.tar, *.tar.*) found\n" + " directly in , with their name:tag\n" " -t, --test run the test suite\n" - " -l, --log-level set log verbosity (trace, debug, info, warn,\n" + " --log-level set log verbosity (trace, debug, info, warn,\n" " error, critical, off)\n" " -h, --help print this help and exit\n" " -V, --version print version information and exit\n", @@ -203,6 +211,17 @@ int cleanup_image(const std::string& layer_id) { return 0; } +int list_images_command(const std::filesystem::path& dir) { + auto images = list_oci_images(dir); + if (!images) { + return 1; + } + for (const auto& ref : *images) { + fmt::print("{}:{}\t{}\n", ref.name, ref.tag, ref.path.filename().string()); + } + return 0; +} + int run_container(const std::filesystem::path& image_tar, const std::vector& command, bool use_nsenter) { auto mounted = mount_image(image_tar); @@ -235,7 +254,7 @@ int main(int argc, char* argv[]) { opterr = 0; int opt; - while ((opt = getopt_long(argc, argv, ":hVtl:m:u:r:c:n", kLongOptions.data(), nullptr)) != -1) { + while ((opt = getopt_long(argc, argv, ":hVtm:u:r:c:nl:", kLongOptions.data(), nullptr)) != -1) { switch (opt) { case 'h': print_usage(argv[0]); @@ -247,7 +266,8 @@ int main(int argc, char* argv[]) { case 'm': case 'u': case 'r': - case 'c': { + case 'c': + case 'l': { Mode requested; switch (opt) { case 't': @@ -262,9 +282,12 @@ int main(int argc, char* argv[]) { case 'r': requested = Mode::kRun; break; - default: + case 'c': requested = Mode::kCleanup; break; + default: + requested = Mode::kListImages; + break; } if (mode != Mode::kNone && mode != requested) { spdlog::error("multiple actions specified"); @@ -280,7 +303,7 @@ int main(int argc, char* argv[]) { case 'n': disable_nsenter = true; break; - case 'l': + case kLogLevelOpt: if (!apply_log_level(optarg)) { return 1; } @@ -315,6 +338,9 @@ int main(int argc, char* argv[]) { if (mode == Mode::kCleanup) { return cleanup_image(mode_arg); } + if (mode == Mode::kListImages) { + return list_images_command(mode_arg); + } if (mode == Mode::kRun) { std::vector command(argv + optind, argv + argc); if (command.empty()) { diff --git a/src/oci_image.cpp b/src/oci_image.cpp index 7a2866e..7078279 100644 --- a/src/oci_image.cpp +++ b/src/oci_image.cpp @@ -22,8 +22,10 @@ #include #include +#include #include #include +#include namespace { @@ -91,6 +93,98 @@ std::optional read_entry_to_string(const std::filesystem::path& tar return content; } +bool matches_tar_glob(const std::filesystem::path& path) { + const std::string name = path.filename().string(); + constexpr std::string_view suffix = ".tar"; + if (name.size() >= suffix.size() && + name.compare(name.size() - suffix.size(), suffix.size(), suffix) == 0) { + return true; + } + return name.find(".tar.") != std::string::npos; +} + +// Filename with everything from the last ".tar" onward stripped, so "foo.tar.gz" +// becomes "foo", not "foo.tar". +std::string archive_basename(const std::filesystem::path& path) { + const std::string name = path.filename().string(); + size_t tar_pos = name.rfind(".tar"); + return tar_pos == std::string::npos ? name : name.substr(0, tar_pos); +} + +struct ParsedRef { + std::string name; + std::string tag; +}; + +// Splits a reference like "[registry/]repo[:tag]" at the last ':' that comes after +// the last '/' (so a registry's own "host:port" isn't mistaken for a tag). A bare +// word with no '/' at all (e.g. "musl-current") is treated as just a tag, matching +// the observed skopeo/podman convention for org.opencontainers.image.ref.name. +ParsedRef parse_image_ref(const std::string& ref) { + size_t last_slash = ref.rfind('/'); + size_t last_colon = ref.rfind(':'); + bool has_tag = last_colon != std::string::npos && + (last_slash == std::string::npos || last_colon > last_slash); + if (has_tag) { + return {ref.substr(0, last_colon), ref.substr(last_colon + 1)}; + } + if (last_slash != std::string::npos) { + return {ref, ""}; + } + return {"", ref}; +} + +std::optional read_image_ref(const std::filesystem::path& tar_path) { + auto layout = read_entry_to_string(tar_path, "oci-layout"); + if (!layout) { + spdlog::debug("{}: not an OCI image tar (missing oci-layout)", tar_path.string()); + return std::nullopt; + } + + auto index_content = read_entry_to_string(tar_path, "index.json"); + if (!index_content) { + spdlog::debug("{}: not an OCI image tar (missing index.json)", tar_path.string()); + return std::nullopt; + } + + json index; + try { + index = json::parse(*index_content); + } catch (const json::parse_error& e) { + spdlog::debug("{}: index.json is not valid JSON: {}", tar_path.string(), e.what()); + return std::nullopt; + } + + json manifest_entry; + bool found = false; + for (const auto& m : index.value("manifests", json::array())) { + if (m.value("mediaType", "") == "application/vnd.oci.image.manifest.v1+json") { + manifest_entry = m; + found = true; + break; + } + } + if (!found) { + spdlog::debug("{}: index.json has no OCI image manifest entry", tar_path.string()); + return std::nullopt; + } + + OciImageRef ref; + ref.path = tar_path; + + auto annotations = manifest_entry.value("annotations", json::object()); + std::string annotation_ref = annotations.value("io.containerd.image.name", ""); + if (annotation_ref.empty()) { + annotation_ref = annotations.value("org.opencontainers.image.ref.name", ""); + } + + ParsedRef parsed = annotation_ref.empty() ? ParsedRef{} : parse_image_ref(annotation_ref); + ref.name = parsed.name.empty() ? archive_basename(tar_path) : parsed.name; + ref.tag = parsed.tag.empty() ? "latest" : parsed.tag; + + return ref; +} + } // namespace std::string oci_digest_hex(std::string_view digest) { @@ -193,3 +287,30 @@ std::optional> read_oci_layers(const std::filesystem::path return layers; } + +std::optional> list_oci_images(const std::filesystem::path& dir) { + std::error_code ec; + if (!std::filesystem::is_directory(dir, ec)) { + spdlog::error("not a directory: {}", dir.string()); + return std::nullopt; + } + + std::vector images; + for (const auto& entry : std::filesystem::directory_iterator(dir, ec)) { + if (!entry.is_regular_file() || !matches_tar_glob(entry.path())) { + continue; + } + if (auto ref = read_image_ref(entry.path())) { + images.push_back(std::move(*ref)); + } + } + if (ec) { + spdlog::error("failed to read directory {}: {}", dir.string(), ec.message()); + return std::nullopt; + } + + std::sort(images.begin(), images.end(), [](const OciImageRef& a, const OciImageRef& b) { + return std::tie(a.name, a.tag) < std::tie(b.name, b.tag); + }); + return images; +} diff --git a/src/oci_image.h b/src/oci_image.h index e4c0902..899717d 100644 --- a/src/oci_image.h +++ b/src/oci_image.h @@ -42,3 +42,18 @@ bool extract_blob_to_file(const std::filesystem::path& tar_path, // Strips the "sha256:" algorithm prefix from a digest string, e.g. // "sha256:abcd" -> "abcd". Returns the input unchanged if there is no such prefix. std::string oci_digest_hex(std::string_view digest); + +struct OciImageRef { + std::string name; + std::string tag; + std::filesystem::path path; // the archive file this was found in +}; + +// Scans `dir` (non-recursively) for files matching *.tar or *.tar.*, and for each one +// that's a valid OCI Image Layout archive, determines an image name/tag from its +// index.json manifest annotations (io.containerd.image.name or +// org.opencontainers.image.ref.name), falling back to the archive's filename (with +// .tar and any compression suffix stripped) for the name and "latest" for the tag. +// Files that aren't valid OCI archives are silently skipped. Returns nullopt if `dir` +// isn't a readable directory; an empty vector is a valid result (nothing matched). +std::optional> list_oci_images(const std::filesystem::path& dir);