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);