Add elfuse oci subcommand for pulling and inspecting images#34
Add elfuse oci subcommand for pulling and inspecting images#34Max042004 wants to merge 7 commits into
Conversation
Lays the first slice of Phase 1 from issue sysprog21#31: the elfuse oci subcommand surface and a self-contained OCI image reference parser. No registry, store, or unpack code lands here; this is the routing and parsing scaffold that every later piece depends on. src/main.c routes argv[1] == "oci" to oci_cli_main before the Hypervisor.framework setup runs, so image distribution never has to satisfy the host DC ZVA assertion or the HVF entitlement check. The existing arg parser, --help, --version, --fork-child, and guest execution paths are otherwise untouched. src/oci/cli.c implements pull, inspect, prune, and list dispatch. inspect parses a reference and prints the canonical form along with the registry, repository, tag, and digest fields, which proves the end-to-end wiring. The remaining subcommands return rc=2 with an explicit "not implemented yet" message rather than crashing or silently succeeding so users get a stable surface to script against. src/oci/ref.c implements the de-facto containerd/docker reference grammar: reference := name [":" tag] ["@" digest] name := [domain "/"] path domain := first slash component containing "." or ":" or equal to "localhost" path := component ("/" component)* component := [a-z0-9]+ ((["._-"] | "__") [a-z0-9]+)* tag := [A-Za-z0-9_] [A-Za-z0-9_.-]{0,127} digest := ("sha256" | "sha512") ":" lowercase-hex Defaults match Docker conventions: missing registry becomes docker.io, single-segment paths under docker.io pick up the library/ prefix, and missing tag/digest defaults the tag to latest. A digest- only reference leaves tag NULL so the canonical form does not fabricate a tag the user never wrote. Digest hex is required to be lowercase because the local content-addressable store will key off the canonical digest string and uppercase encodings would otherwise cause silent dedup misses. memrchr is GNU-only and Darwin libc does not ship it, so a small memrchr_local helper handles the rightmost-slash search the tag detector needs. The looks_like_domain helper compares localhost as a 9-byte literal (the earlier draft had a length bug here that the unit tests caught). tests/test-oci-ref.c is a native macOS test program (not cross- compiled, no Hypervisor.framework, no codesign) that links directly against src/oci/ref.c. It runs 14 happy-path cases covering Docker defaults, registry detection, port handling, sha256 and sha512 digests, tag+digest pinning, and every separator variant in the component grammar, plus 20 error cases covering empty input, NULL input, uppercase, malformed digests, double @, empty tag/digest suffixes, length limits, and structural validation. All 34 cases pass. mk/config.mk adds tests/test-oci-ref.c to NATIVE_TESTS so the cross- compile pattern rule does not pick it up. Makefile adds the link rule for build/test-oci-ref (no codesign because there is no HVF dependency). mk/tests.mk exposes test-oci-ref as a phony target and runs it as the last stage of make check, alongside the existing proctitle, busybox, sysroot, and timeout-disable validations.
Second slice of Phase 1 from issue sysprog21#31. Lands the on-disk storage substrate that the upcoming registry client will spill manifests, configs, and layers into. No HTTP, no unpack, no CLI surface yet; this slice is intentionally a pure library plus offline unit tests so the storage semantics can be audited without standing up a network. src/oci/digest.{c,h} wraps CommonCrypto SHA-256 and SHA-512 in a streaming digester so multi-gigabyte layers can be hashed without buffering. Calls into CommonCrypto are clamped to 1 GiB chunks because CC_LONG is 32-bit and OCI layers can legitimately exceed that. Hex output is lowercase to match the reference parser (src/oci/ref.c); the OCI image reference grammar already rejects uppercase digest hex, so the entire pipeline -- parser, manifest fetcher, local store -- shares one canonical encoding and cannot silently miss a dedup match. A separate one-shot helper, hex validator, and "<algo>:<hex>" parser sit on top of the same streaming primitive. src/oci/blob-store.{c,h} is the content-addressable store. Layout matches the OCI image-layout convention: <root>/blobs/<algo>/<hex> for committed blobs plus <root>/tmp/blob-<pid>-<seq>-XXXXXX for the in-flight staging file. mkstemp supplies global uniqueness; an in-process counter is added to the template so failures of the rand pool cannot defeat in-process disambiguation. The commit path hashes streamed bytes, fsyncs the staging file, and uses link(2) rather than rename(2) to publish the final inode. link returning EEXIST is the dedup hit signal: two writers racing on the same digest both unlink their staging files and report success, because the content is by definition identical when the digest matched. Digest mismatch returns -1 with errno EINVAL and unlinks the staging file, so an interrupted or hostile pull never leaves a visible-complete blob behind. The abort path takes the same cleanup. STORE_PATH_MAX is set comfortably above PATH_MAX so snprintf truncation cannot silently corrupt a path; callers passing smaller buffers still detect overflow via the return value. Per oci-roadmap.md Q1, the store will eventually sit on a case-sensitive APFS sparse volume managed by elfuse, but the volume bootstrap is its own later slice. For now the store API takes a plain directory path; the same API survives the volume migration unchanged. tests/test-oci-digest.c exercises 25 cases: NIST FIPS-180-4 vectors (empty, "abc", 56-byte, one-million-'a') for both SHA-256 and SHA-512, the same one-million-'a' streamed in 4 KiB and 17-byte chunks to lock down the chunking loop, hex validator boundary cases, and every "<algo>:<hex>" parse rejection (missing colon, unknown algorithm, short hex, uppercase hex, NULL input). NULL and zero- length updates must be safe and must not perturb the running state. tests/test-oci-blob-store.c drives 14 cases inside an mkdtemp scratch directory: layout creation, idempotent reopen, path() formatting, one-shot put + has() round-trip, dedup commit leaves the same inode, digest mismatch is rejected with EINVAL and tmp/ stays empty, streaming writer over multiple chunks, abort leaves no leftover, and close + reopen still sees the committed blob (issue sysprog21#31 DoD: "store survives restart"). dir_is_empty / path_is_dir / path_is_file helpers keep the assertions terse. Makefile adds oci/digest.c and oci/blob-store.c to SRCS, plus the two new native-test link rules. mk/config.mk extends NATIVE_TESTS so the cross-compile pattern rule does not pick the new tests up. mk/tests.mk exposes test-oci-digest and test-oci-blob-store as phony targets and runs them as the final two stages of make check, beside the existing test-oci-ref stage. All 39 (25 + 14) new assertions pass; the rest of make check stays green (unit suite 81 passed / 0 failed, busybox, proctitle, procfs-exec, timeout-disable, OCI-ref 34/34).
Third slice of Phase 1 from issue sysprog21#31. Lands the JSON deserialization substrate the upcoming registry client will run every fetched manifest, index, and config blob through. No HTTP, no unpack, no CLI surface yet; this slice is intentionally a pure offline library plus a 76-case unit test driven by inline JSON fixtures so the parse contract is auditable without standing up a network. externals/cjson/ vendors cJSON v1.7.18 verbatim (MIT-licensed, single .c/.h pair) per oci-roadmap.md Q9. No local modifications; future security updates re-fetch via the three curl commands in externals/cjson/VENDORING.md. .gitignore switches from ignoring all of externals/ to ignoring externals/* with an explicit !externals/cjson/ exception so the vendored tree stays tracked while the downloaded test fixtures stay out of git. The Makefile compiles cJSON with the same project CFLAGS the rest of the codebase uses; cJSON happens to be clean under -Wall -Wextra -Wpedantic on this version, so no per-file warning override is required. src/oci/media-type.{c,h} is the canonical enum + table for every OCI and Docker media type the manifest/index/config/layer code branches on. Foreign (nondistributable) layers are recognized and distinguishable so the parser can name the actual offending layer type instead of collapsing them to a generic "unknown", but the supported-layer predicate excludes them per oci-roadmap.md Q3 (elfuse cannot fetch the out-of-band payload they reference). The parser strips charset/boundary parameters and surrounding whitespace before lookup so the registry's Content-Type header value canonicalizes the same way the manifest's mediaType JSON field does. src/oci/manifest.{c,h} parses image manifests, image indexes, and image configs against schemaVersion 2. Every descriptor digest is validated through oci_digest_parse so a parsed oci_descriptor_t carries both the original "<algo>:<hex>" string and a populated (algo, hex[]) pair the blob store from slice 2 can consume directly. Size fields go through a fractional-part / negative / round-trip-precision check because cJSON returns numbers in a double; the parser rejects sizes beyond 2**53 - 1 where IEEE 754 precision starts dropping integers and rejects fractional sizes that would otherwise truncate silently to a near-but-wrong integer. Manifest config descriptors are required to carry a config media type, layer descriptors must carry a layer media type, and foreign layers are rejected with a precise error. Image configs require rootfs.type == "layers" (the only value the OCI image-spec defines) and validate every rootfs.diff_ids entry as a lowercase digest. Platform fields default empty variant / os.version strings to "" rather than NULL so the selector can use unconditional strcmp. oci_index_pick_linux_arm64 prefers variant "v8", then empty variant, then any other arm64 variant. It also skips entries whose manifest media type is not recognized -- even when the platform matches, the registry-fetch path cannot consume the resulting manifest, so picking such an entry would only defer a failure. tests/test-oci-manifest.c exercises 76 cases inline: every recognized media type lookup, charset/whitespace stripping, NULL and bogus strings, every predicate, both compression results; OCI and Docker happy-path manifest parses with two-layer gzip + zstd mix; the seven manifest rejection paths (malformed JSON, schemaVersion != 2, missing config, uppercase digest, negative size, fractional size, foreign layer, non-config media type on the config descriptor); the four index paths (multi-arch v8 wins; no-v8 picks empty variant over v7; no linux/arm64 returns NULL; Docker manifest list; unknown manifest mediaType is recorded but the selector skips it); and the four image config paths (happy with User/Env/Entrypoint/Cmd/WorkingDir/diff_ids; missing rootfs; non-layers rootfs.type; malformed diff_id). Makefile / mk/config.mk / mk/tests.mk wire the new translation units into elfuse's link line, add oci/media-type.o + oci/manifest.o + the vendored cJSON object, register tests/test-oci-manifest.c in NATIVE_TESTS so the cross-compile pattern rule does not pick it up, and run the new test as the final stage of make check beside the existing test-oci-ref / test-oci-digest / test-oci-blob-store stages. All 76 new assertions pass; the rest of make check stays green (unit suite 81 passed / 0 failed / 3 skipped, busybox, proctitle, procfs-exec, timeout-disable, OCI-ref 34/34, OCI-digest 25/25, OCI-blob-store 14/14). elfuse oci pull / prune / list still return rc=2; wiring the parser into the CLI is gated on slice 4 (HTTPS + token challenge + blob fetch). The parsers exist now so that work can land without also adding deserialization.
Fourth slice of Phase 1 from issue sysprog21#31, split into 4a here. Lands the HTTP fetch substrate that connects the slice-3 manifest parsers to a real registry and streams blob bodies into the slice-2 content-addressed store, all behind a single fetcher handle. No CLI wiring yet (elfuse oci pull still returns rc=2); slice 5 connects the pull command to this layer, persists the manifest graph, and pins the resolved tag-to-digest. Slice 4 was cut into 4a / 4b per oci-roadmap.md Q7 so each slice stays under the ~800 LOC review budget. 4a covers the anonymous Docker Hub / GHCR public-pull subset: anonymous GET, 401 + Www-Authenticate Bearer challenge, token fetch, retry, blob streaming with declared-size cap and on-commit digest verification. 4b will add basic auth, --insecure-ca custom CA, and --insecure loopback-gated TLS verify off. src/oci/fetch.{c,h} wraps libcurl. A fetcher owns one CURL easy handle, one cached bearer token, and the most recent Www-Authenticate challenge. The first request is anonymous. If the registry replies 401, the header parser captures realm / service / scope, fetch_token GETs the realm with those parameters, the JSON response is parsed with cJSON, and the original request is retried once with Authorization: Bearer <token>. The cached token is reused for subsequent calls on the same fetcher so a manifest plus N layer pulls cost one token round trip rather than N+1. docker.io is rewritten to registry-1.docker.io because the reference parser stores the canonical name while the actual API host differs. The blob path is content-addressed end to end. oci_fetch_blob short circuits when the descriptor is already present in the store; otherwise it opens an oci_blob_writer keyed by the descriptor digest, streams response body chunks through the writer, and tracks a running byte count capped at the descriptor's declared size so a hostile server cannot stream forever. The writer's own digest check at commit time rejects any payload that hashes to anything other than the descriptor hex. Size mismatch, digest mismatch, transport error, and non-2xx all unwind via oci_blob_writer_abort so an interrupted pull never leaves a visible-complete blob behind. CURLOPT_FOLLOWLOCATION is enabled so the common case where a registry 307s blob fetches to S3 / Cloudfront with a pre-signed URL works transparently; libcurl strips the Authorization header on cross-host redirects, which is exactly what the storage backend expects. The header parser keys on Content-Type, Docker-Content-Digest, and Www-Authenticate. Content-Type is stripped of charset/parameters before the manifest parser sees it so the canonicalization matches the mediaType field inside the JSON body. Docker-Content-Digest is captured verbatim so the upcoming tag-to-digest pinning in slice 5 can record the registry's resolved digest without recomputing. Response body accumulation has a 16 MiB ceiling (FETCH_BODY_MAX) so an unbounded reply cannot fill memory; real manifests, indexes, and image configs are orders of magnitude below this. Blob responses bypass the buffer entirely and stream straight through the writer. tests/test-oci-fetch.c spawns an in-process HTTP/1.1 mock server bound to 127.0.0.1 on an ephemeral port and drives the fetcher against scripted handlers. Nine offline cases exercise anonymous manifest GET (body, Content-Type stripping, Docker-Content-Digest capture); manifest 404 surfaces with the right status; bearer challenge runs the full 401 then token then retry sequence and inspects the request log to verify the second hop hits /token and the third carries the Bearer header; cached token reuse on a second fetch confirms no re-challenge round trip; blob success commits a known-good payload to the store; already-cached blob short-circuits with zero server requests; oversize response is rejected and leaves no visible blob; digest mismatch on a correctly-sized payload is rejected at commit; blob 404 fails cleanly. An opt-in tenth case behind OCI_FETCH_ONLINE=1 pulls alpine:3.20 from Docker Hub through the real bearer flow as a smoke test; it is wired as make test-oci-fetch-online and is not part of make check. Makefile adds src/oci/fetch.c to SRCS and -lcurl to HVF_LDFLAGS so the production elfuse binary links libcurl from the macOS SDK (no vendoring per oci-roadmap.md Q7 and Q9). build/test-oci-fetch links libcurl plus pthread for the mock server. mk/config.mk registers the test source in NATIVE_TESTS so the cross-compile pattern rule does not try to aarch64-compile it. mk/tests.mk adds test-oci-fetch as the final stage of make check and exposes test-oci-fetch-online as a separate target. make check stays green: 78 unit tests, busybox 81/0/3, proctitle, procfs-exec, timeout-disable, OCI-ref 34/34, OCI-digest 25/25, OCI-blob-store 14/14, OCI-manifest 76/76, OCI-fetch 9/9.
…ecure) Fourth slice of Phase 1 from issue sysprog21#31, 4b half. Closes out the oci-roadmap.md Q7 ship list by extending the slice-4a fetcher with HTTP Basic authentication, custom CA bundle, and a loopback-gated TLS verify-off path. fetch_manifest / fetch_blob signatures are unchanged; everything new lives in oci_fetcher_options_t and a new per-easy-handle helper. src/oci/fetch.h grows four fields on oci_fetcher_options_t: username, password, ca_file, allow_insecure. oci_fetcher_new now stashes username/password as a pre-joined "user:pass" string (CURLOPT_USERPWD takes the joined form), strdup's ca_file, and records allow_insecure verbatim. apply_security_opts() is called from every GET callsite (perform_manifest_get, perform_blob_get, fetch_token) right after curl_easy_reset, which attaches CURLOPT_USERPWD plus CURLAUTH_BASIC, CURLOPT_CAINFO, and CURLOPT_SSL_VERIFY{PEER,HOST}=0 when each is set. This shape gives the token endpoint the basic credentials too: a registry that bridges Basic for the token exchange and Bearer for the data API sees both. libcurl drops the USERPWD-derived Authorization header in favor of the manually appended Authorization: Bearer on the retry, so basic gives way to bearer once a token is in hand. The loopback policy gate runs at the entry of oci_fetch_manifest and oci_fetch_blob, not in oci_fetcher_new: ref is not available at construction time, and policy is about which host the fetcher is actually about to talk to. extract_host_from_registry strips the optional :port (and the [] of bracketed IPv6 literals) from ref->registry, is_loopback_host case-insensitively matches against 127.0.0.1 / localhost / ::1, and check_insecure_policy combines them so a non-loopback target with allow_insecure=true returns -1 with errno=EPERM before a single byte is sent. The policy reads ref->registry rather than the test-only base_url_override so unit tests can drive a non-loopback ref while still pointing the mock URL at 127.0.0.1, and the production surface (no override) gets the same answer it would in deployment. tests/test-oci-fetch.c upgrades the in-process mock from plain HTTP to TLS. The mock generates an ephemeral RSA-2048 keypair and a self-signed certificate at startup via OpenSSL EVP, signed for CN=127.0.0.1 with SAN IP:127.0.0.1 + DNS:localhost, valid for one day. The certificate PEM is written into the scratch directory and the fetcher receives the path through opts.ca_file. accept loop wraps each connection in SSL_accept; read/write go through a small io_t abstraction so handler signatures change only in the IO parameter type. mock_send_full keeps the same response shape but writes through SSL_write. libcurl's SSL backend is forced to OpenSSL (LibreSSL on macOS) via curl_global_sslset() called before any other libcurl entry. macOS system libcurl is a multi-SSL build that defaults to Secure Transport, and Secure Transport ignores CURLOPT_CAINFO. Without this pin the ca_file negative cases would pass for the wrong reason: the handshake would succeed against the keychain, not the supplied PEM. LibreSSL on macOS still finds the system trust roots for the OCI_FETCH_ONLINE=1 case, so the online docker.io smoke test continues to work. mk/toolchain.mk auto-detects OPENSSL_PREFIX from /opt/homebrew/opt/openssl@3 (Apple Silicon) or /usr/local/opt/openssl@3 (Intel) and exposes OPENSSL_CFLAGS / OPENSSL_LDFLAGS. The Makefile attaches them only to build/test-oci-fetch (target-specific CFLAGS plus link flags), so the production elfuse binary still has no OpenSSL dependency: the new TLS plumbing is testing scaffolding, not runtime code. Test count grows from 9 to 15 cases. New cases: basic auth success (verifies the server saw "Basic YWxpY2U6c2VjcmV0" exactly once); basic auth carried into the token endpoint (verifies the token GET saw the same basic credentials and the manifest retry switched to Bearer); insecure on a loopback registry is allowed (HTTPS request goes through despite no ca_file); insecure on a non-loopback registry is rejected with errno=EPERM and zero bytes leak to the mock server (request log stays empty); ca_file unset against the self-signed mock fails the handshake with http_status=0; ca_file pointing at an unrelated self-signed certificate also fails the handshake. The 9 existing cases continue to pass over TLS by supplying the mock's CA PEM as ca_file. make check stays green: 78 unit tests, busybox 81/0/3, proctitle, procfs-exec, timeout-disable, OCI-ref 34/34, OCI-digest 25/25, OCI-blob-store 14/14, OCI-manifest 76/76, OCI-fetch 15/15. make test-oci-fetch-online (opt-in) also passes.
Slice 5a of Phase 1 from issue sysprog21#31. Wires the slice 4a/4b fetcher and the slice 3 manifest parser into the elfuse oci pull command and persists the resolved blob graph on disk. inspect still renders only the canonical reference; the offline manifest-tree renderer ships in slice 5b. src/oci/store.{c,h} wraps the slice-2 content-addressable blob store with a tag-to-digest pin table. On-disk layout under <root>: blobs/<algo>/<hex> (immutable, from slice 2) tmp/blob-<pid>-<seq>-XXXXXX (in-flight staging) refs/<registry>/<repository>/<tag> (pin file, one line: <algo>:<hex>) oci_store_open creates the refs/ subtree, then opens a blob store rooted at the same path so the two layers share one directory. oci_store_put_ref refuses digest-only refs (their digest is the pin, no file needed), validates the supplied digest string with oci_digest_parse, mkdir -p's the registry/repository prefix on demand, writes <digest>\n into a tmp file alongside the final path, fsyncs, and renames into place. Rename rather than link because tag pins are mutable: pulling alpine:3.20 today may resolve to a different digest than yesterday and overwriting the pin is the correct semantic. The blob layer keeps its link(2) discipline because content-addressed blobs stay immutable. oci_store_get_ref reads the pin file, strips the trailing newline, validates the digest via oci_digest_parse, and returns a heap- allocated copy. Miss reports errno=ENOENT so callers can distinguish "never pulled" from "io error reading pin". oci_store_default_root returns the platform default: $XDG_DATA_HOME/ elfuse/store when set, otherwise $HOME/Library/Application Support/ elfuse/store. Phase 2 will mount a sparse case-sensitive APFS volume at the same path (oci-roadmap.md Q1); the API does not change. src/oci/pull.{c,h} implements the pipeline. oci_pull runs five phases linearly: 1. Fetch the top-level manifest by ref->digest or ref->tag, advertising Accept for both OCI and Docker index + manifest types. 2. Hash the body with SHA-256 and cross-check against the Docker-Content-Digest header when the registry sent one. Body / header mismatch is a hostile-registry signal and aborts before anything else writes to the store. When the user pulled by digest, also cross-check the body digest against ref->digest. 3. Persist the manifest body into blob store at sha256:<computed-hex>. 4. If the top-level was an image index, parse it, run oci_index_pick_linux_arm64, fetch the sub-manifest by its descriptor digest with expected-digest verification, persist it, and switch to the sub-manifest body for the next phase. The pin digest stays at the top-level (index) digest so that the next inspect / pull by tag re-walks index then manifest. 5. Parse the manifest, fetch the config blob, fetch each layer blob in manifest order via oci_fetch_blob. Each blob fetch short- circuits when oci_blob_store_has reports a hit, so a re-pull issues zero layer downloads (only the two manifest bodies are re-fetched in the index case; manifest caching is its own future slice). 6. Write the tag-to-manifest-digest pin via oci_store_put_ref. Skip for digest-only refs (no tag to pin). Schema v1 manifests and foreign / nondistributable layers are rejected by oci_manifest_parse from slice 3; oci_pull surfaces those diagnostics and aborts before any partial layer hits the store. The errno preserved across the cleanup goto so callers can key tests off EPROTO / ENOENT / EINVAL without seeing free()'s leftover stomp. Progress output is one line per descriptor with a truncated digest, size, state (downloaded vs cached), and media-type name. -q / --quiet silences it. The full hex still goes into the pin file and the blob store for verification. src/oci/cli.c grows pull argument parsing: --store DIR, -u | --user USER[:PASS], --insecure-ca PEM, --insecure, -q | --quiet, plus the positional reference. Defaults come from oci_store_default_root. split_userpass handles "user", "user:", and "user:pass" forms with one dynamically-allocated buffer the cleanup path frees. inspect, prune, list keep their slice-1 behaviour for now. tests/lib/oci-mock.{c,h} extracts the TLS-terminated HTTP/1.1 mock server from test-oci-fetch.c. The accept loop, ephemeral self-signed RSA-2048 + SAN cert generator, header parser, request log, and mock_send_full response helper all move out so both the fetch and the pull suites share one ~400 LOC implementation. Public symbols gain an oci_mock_ prefix to make the helper boundary explicit. Three small helpers (wipe_dir, scratch_root, base_url) tag along because both suites need them. test-oci-fetch.c shrinks by 380 lines, switches to the new header, and keeps its 15/15 passing. tests/test-oci-store.c covers 9 cases: layout creation, put + get round trip, miss returns ENOENT with out_digest=NULL, digest-only ref is rejected with EINVAL (its digest is the pin), malformed digest string is rejected with EINVAL, deep repository slashes get mkdir -p, pin overwrite replaces the file, blob and pin share the same root, and default_root respects XDG_DATA_HOME / falls back to HOME. tests/test-oci-pull.c covers 6 end-to-end cases against the mock. The test builds a synthetic image at runtime: three layer byte strings, one image config JSON referencing the layer digests, one manifest JSON referencing the config + layer digests, one index JSON referencing the manifest digest. All five digests are real SHA-256 of the actual bytes the mock serves, so the cross-check inside oci_pull exercises a real verification path. The cases are: tag resolves to index resolves to arm64 sub-manifest with config + 3 layers stored and pin written; tag resolves directly to manifest (no index) with pin written; digest- only ref pulls but no pin is written (and get_ref returns EINVAL); re-pull short-circuits layer + config downloads (second pull issues exactly 2 requests: index + sub-manifest); body / Docker-Content-Digest mismatch aborts with EPROTO and no pin written; index without linux/arm64 entry aborts with ENOENT. Makefile / mk/config.mk / mk/tests.mk wire the new translation units: oci/store.o and oci/pull.o join SRCS; test-oci-store.c and test-oci-pull.c land in NATIVE_TESTS so the cross-compile rule skips them; new link rules build test-oci-store and test-oci-pull; tests/lib/ oci-mock.o is a separate object linked into both test-oci-fetch and test-oci-pull with OPENSSL_CFLAGS applied; make check gains two new stages running test-oci-store and test-oci-pull after the existing OCI suites. make check stays fully green: 78 unit tests; busybox 81/0/3; proctitle low-stack; procfs-exec; timeout-disable; OCI-ref 34/34; OCI-digest 25/25; OCI-blob-store 14/14; OCI-manifest 76/76; OCI-fetch 15/15; OCI-store 9/9; OCI-pull 6/6. make test-oci-fetch-online (opt-in) still passes.
Slice 5b of Phase 1 from issue sysprog21#31. Closes out Phase 1 by giving elfuse oci inspect an actual function beyond the slice-1 canonical-ref print: it reads the local store the slice 5a pull pipeline populated and renders the manifest graph without touching the network. Phase 2 follows: sparse APFS volume bootstrap, layer unpack with whiteouts, clonefile copy-up. src/oci/inspect.{c,h} owns the offline renderer. oci_inspect resolves the manifest digest in three steps: 1. ref->digest when set (digest-pinned reference) 2. pin file <root>/refs/<registry>/<repository>/<tag> when ref->tag is set 3. Neither: print "(no local manifest; run 'elfuse oci pull' first)" on stdout and return 0. This preserves the slice-1 inspect smoke output shape for refs that were never pulled. The pinned digest goes through oci_digest_parse to reject corrupt pin files, then read_blob_file slurps <root>/blobs/<algo>/<hex> into a heap buffer. read_blob_file caps the read at 64 MiB (real manifests are well under 1 MiB; the cap prevents a corrupted store from forcing a pathological malloc) and reports errno=ENOENT when the blob file is absent. Classification between index and manifest is structural: the slice-3 parsers reject disjoint shapes (oci_index_parse requires a manifests array; oci_manifest_parse requires config + layers), so trying index first and falling back to manifest is unambiguous. Image config blobs never reach this path because pins point at manifest-shaped blobs. Index rendering prints a platforms table. Default mode shows only the picked linux/arm64 entry (tagged "[arm64]") and drills into the sub-manifest blob to print its config descriptor + layer table. The --all-platforms flag lists every platform entry and skips the drill; the flag answers "what does this image cover", not "what is inside the arm64 variant". Both decisions are documented inline at the oci_inspect_options_t definition. Failure mode for a partial store: index loads fine but the linux/arm64 sub-manifest blob is missing. The platform table still goes to stdout (the user sees what is available), a warning lands on stderr, and the call returns -1 with errno=ENOENT and err_msg = "indexed manifest blob missing from local store". Scripts key on the exit code; humans read the table. The errno is preserved across the cleanup goto in the same shape slice-5a oci_pull adopted. Digest formatting follows the slice-5a progress lines for visual consistency: full digests appear in the pinned: line and in index entry tagging (so users can copy / grep the exact value), and a 22- column short form ("sha256:" + 12 hex + "...") appears in the layer tables. short_digest takes a caller-supplied buffer so two short digests in one printf do not clobber a shared static. src/oci/cli.c grows parse_inspect_args + a cmd_inspect rewrite. The new flag set is --store DIR (override the platform default) and --all-platforms (the flag described above); the canonical-ref header print stays in cli.c so the slice-1 smoke output continues working when the store has no record. After the header, cmd_inspect opens the store and calls oci_inspect. rc 0 means success or pin miss; rc 1 means a real failure (malformed blob, blob missing, IO). tests/test-oci-inspect.c drives 6 cases against a pre-populated scratch store. The store is built directly with oci_blob_store_put_ bytes + oci_store_put_ref, not through oci_pull, so the test stays independent of the slice-4 fetcher and the slice-5a pipeline. open_memstream captures stdout into a heap buffer and the assertions grep for distinctive substrings (digest hex prefixes, "[arm64]", section headers) so format tweaks do not cause spurious failures. The 6 cases are: a direct image manifest (config + 2 layers, asserts no [2] index appears so off-by-one shows up); an image index where default mode drills the arm64 sub-manifest and amd64 / s390x stay hidden; the same index with --all-platforms (all three platforms listed, drill section absent); a pin miss for an unknown tag (rc=0, informational line); a digest reference whose blob is absent (rc=-1, errno=ENOENT, "error: manifest blob ... not found"); and the index-ok sub-manifest-missing case (stdout still has the platform table, rc=-1, errno=ENOENT, err_msg identifies the missing inner blob). The last case dup2's stderr to /dev/null around the run so the warning line does not pollute the test driver output. Makefile adds oci/inspect.c to SRCS. mk/config.mk registers tests/test-oci-inspect.c in NATIVE_TESTS so the cross-compile pattern rule skips it. The new link rule pulls in inspect.o, store.o, blob-store.o, digest.o, manifest.o, media-type.o, ref.o, and cJSON; no libcurl, no openssl. mk/tests.mk gains a test-oci-inspect target and runs it as a make-check stage after OCI-pull. make check stays fully green: 78 unit tests; busybox 81/0/3; proctitle low-stack; procfs-exec; timeout-disable; OCI-ref 34/34; OCI-digest 25/25; OCI-blob-store 14/14; OCI-manifest 76/76; OCI-fetch 15/15; OCI-store 9/9; OCI-pull 6/6; OCI-inspect 6/6. make test-oci-fetch-online (opt-in) still passes. elfuse oci inspect now has a real second pane: the slice-1 canonical header followed by either the rendered manifest tree or a clear "never pulled" notice. prune and list still return rc=2.
There was a problem hiding this comment.
11 issues found across 40 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="src/oci/pull.c">
<violation number="1" location="src/oci/pull.c:253">
P2: Error-path leak: `sub_resp` may be allocated but not freed when sub-manifest fetch fails before `have_sub` is set.</violation>
</file>
<file name="src/oci/media-type.c">
<violation number="1" location="src/oci/media-type.c:100">
P2: Media type parsing is case-sensitive, but media type type/subtype tokens are case-insensitive; valid values with different casing will be misclassified as unknown.</violation>
</file>
<file name="src/oci/ref.c">
<violation number="1" location="src/oci/ref.c:83">
P2: Repository-path validation incorrectly rejects valid names with repeated dashes (for example `my--repo`).</violation>
<violation number="2" location="src/oci/ref.c:356">
P2: `docker.io` default-namespace detection is case-sensitive, so mixed-case hostnames can skip the required `library/` prefix.</violation>
</file>
<file name="src/oci/fetch.c">
<violation number="1" location="src/oci/fetch.c:782">
P2: Manifest fetch skips bearer-challenge parsing when a token is already cached, so 401 responses from expired/stale tokens are not retried with a refreshed token.</violation>
<violation number="2" location="src/oci/fetch.c:945">
P2: Blob fetch also disables challenge parsing when a token is cached, preventing 401-triggered token refresh and causing avoidable pull failures.</violation>
</file>
<file name="src/oci/blob-store.c">
<violation number="1" location="src/oci/blob-store.c:354">
P2: The commit path is not crash-durable because it never fsyncs the destination directory after linking the blob into place.</violation>
</file>
<file name="src/oci/store.c">
<violation number="1" location="src/oci/store.c:285">
P2: Fsync the pin directory after `rename` to make tag->digest updates crash-safe; file fsync alone does not persist the directory entry change.</violation>
</file>
<file name="src/oci/manifest.c">
<violation number="1" location="src/oci/manifest.c:295">
P2: `schemaVersion` parsing can accept fractional JSON numbers because `valueint` is used without an integer round-trip check.</violation>
<violation number="2" location="src/oci/manifest.c:385">
P2: Layer descriptor memory is leaked on post-parse validation failures because `nlayers` is incremented too late.</violation>
<violation number="3" location="src/oci/manifest.c:481">
P2: Index descriptor memory leaks when platform parsing fails because `nentries` is incremented after the fallible parse.</violation>
</file>
Tip: cubic can generate docs of your entire codebase and keep them up to date. Try it here.
Re-trigger cubic
| fflush(progress); | ||
| } | ||
|
|
||
| if (fetch_and_persist_manifest(fetcher, store, ref, |
There was a problem hiding this comment.
P2: Error-path leak: sub_resp may be allocated but not freed when sub-manifest fetch fails before have_sub is set.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/oci/pull.c, line 253:
<comment>Error-path leak: `sub_resp` may be allocated but not freed when sub-manifest fetch fails before `have_sub` is set.</comment>
<file context>
@@ -0,0 +1,346 @@
+ fflush(progress);
+ }
+
+ if (fetch_and_persist_manifest(fetcher, store, ref,
+ entry->desc.digest_str,
+ entry->desc.digest_str, &sub_resp,
</file context>
| return OCI_MT_UNKNOWN; | ||
|
|
||
| for (size_t i = 0; i < MEDIA_TYPE_COUNT; i++) { | ||
| if (!strcmp(MEDIA_TYPES[i].name, buf)) |
There was a problem hiding this comment.
P2: Media type parsing is case-sensitive, but media type type/subtype tokens are case-insensitive; valid values with different casing will be misclassified as unknown.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/oci/media-type.c, line 100:
<comment>Media type parsing is case-sensitive, but media type type/subtype tokens are case-insensitive; valid values with different casing will be misclassified as unknown.</comment>
<file context>
@@ -0,0 +1,189 @@
+ return OCI_MT_UNKNOWN;
+
+ for (size_t i = 0; i < MEDIA_TYPE_COUNT; i++) {
+ if (!strcmp(MEDIA_TYPES[i].name, buf))
+ return MEDIA_TYPES[i].kind;
+ }
</file context>
| } else { | ||
| return false; | ||
| } | ||
| if (i >= len || !is_lower_alnum(s[i])) |
There was a problem hiding this comment.
P2: Repository-path validation incorrectly rejects valid names with repeated dashes (for example my--repo).
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/oci/ref.c, line 83:
<comment>Repository-path validation incorrectly rejects valid names with repeated dashes (for example `my--repo`).</comment>
<file context>
@@ -0,0 +1,429 @@
+ } else {
+ return false;
+ }
+ if (i >= len || !is_lower_alnum(s[i]))
+ return false;
+ }
</file context>
| goto oom; | ||
|
|
||
| bool needs_library_prefix = | ||
| strcmp(out->registry, DEFAULT_REGISTRY) == 0 && |
There was a problem hiding this comment.
P2: docker.io default-namespace detection is case-sensitive, so mixed-case hostnames can skip the required library/ prefix.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/oci/ref.c, line 356:
<comment>`docker.io` default-namespace detection is case-sensitive, so mixed-case hostnames can skip the required `library/` prefix.</comment>
<file context>
@@ -0,0 +1,429 @@
+ goto oom;
+
+ bool needs_library_prefix =
+ strcmp(out->registry, DEFAULT_REGISTRY) == 0 &&
+ memchr(path_start, '/', path_len) == NULL;
+ if (needs_library_prefix) {
</file context>
| bearer_challenge_t challenge = {0}; | ||
| long status = 0; | ||
| int rc = perform_blob_get(f, url, &bctx, &status, | ||
| f->bearer_token ? NULL : &challenge, err_msg); |
There was a problem hiding this comment.
P2: Blob fetch also disables challenge parsing when a token is cached, preventing 401-triggered token refresh and causing avoidable pull failures.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/oci/fetch.c, line 945:
<comment>Blob fetch also disables challenge parsing when a token is cached, preventing 401-triggered token refresh and causing avoidable pull failures.</comment>
<file context>
@@ -0,0 +1,1002 @@
+ bearer_challenge_t challenge = {0};
+ long status = 0;
+ int rc = perform_blob_get(f, url, &bctx, &status,
+ f->bearer_token ? NULL : &challenge, err_msg);
+ if (rc < 0) {
+ free(url);
</file context>
| return -1; | ||
| } | ||
|
|
||
| if (link(w->tmp_path, final_path) < 0) { |
There was a problem hiding this comment.
P2: The commit path is not crash-durable because it never fsyncs the destination directory after linking the blob into place.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/oci/blob-store.c, line 354:
<comment>The commit path is not crash-durable because it never fsyncs the destination directory after linking the blob into place.</comment>
<file context>
@@ -0,0 +1,399 @@
+ return -1;
+ }
+
+ if (link(w->tmp_path, final_path) < 0) {
+ if (errno != EEXIST) {
+ int saved = errno;
</file context>
| *err_msg = "close on pin tmp file failed"; | ||
| return -1; | ||
| } | ||
| if (rename(tmp, path) < 0) { |
There was a problem hiding this comment.
P2: Fsync the pin directory after rename to make tag->digest updates crash-safe; file fsync alone does not persist the directory entry change.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/oci/store.c, line 285:
<comment>Fsync the pin directory after `rename` to make tag->digest updates crash-safe; file fsync alone does not persist the directory entry change.</comment>
<file context>
@@ -0,0 +1,360 @@
+ *err_msg = "close on pin tmp file failed";
+ return -1;
+ }
+ if (rename(tmp, path) < 0) {
+ int saved = errno;
+ unlink(tmp);
</file context>
| goto fail; | ||
| const cJSON *plat = | ||
| cJSON_GetObjectItemCaseSensitive(entry, "platform"); | ||
| if (parse_platform(plat, &slot->platform, err_msg) < 0) |
There was a problem hiding this comment.
P2: Index descriptor memory leaks when platform parsing fails because nentries is incremented after the fallible parse.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/oci/manifest.c, line 481:
<comment>Index descriptor memory leaks when platform parsing fails because `nentries` is incremented after the fallible parse.</comment>
<file context>
@@ -0,0 +1,707 @@
+ goto fail;
+ const cJSON *plat =
+ cJSON_GetObjectItemCaseSensitive(entry, "platform");
+ if (parse_platform(plat, &slot->platform, err_msg) < 0)
+ goto fail;
+ out->nentries++;
</file context>
| if (parse_platform(plat, &slot->platform, err_msg) < 0) | |
| out->nentries++; | |
| if (parse_platform(plat, &slot->platform, err_msg) < 0) | |
| goto fail; |
| if (parse_descriptor(desc, &out->layers[out->nlayers], err_msg) < 0) | ||
| goto fail; | ||
| oci_media_type_t lmt = out->layers[out->nlayers].media_type; | ||
| if (!oci_media_type_is_layer(lmt)) { | ||
| set_parse_err(err_msg, | ||
| "manifest layer has non-layer media type"); | ||
| goto fail; | ||
| } | ||
| if (oci_media_type_is_foreign(lmt)) { | ||
| set_parse_err(err_msg, | ||
| "manifest references foreign (nondistributable) " | ||
| "layer; not supported"); | ||
| goto fail; | ||
| } | ||
| if (!oci_media_type_is_layer_supported(lmt)) { | ||
| set_parse_err(err_msg, | ||
| "manifest layer media type is not supported " | ||
| "(only tar / tar+gzip / tar+zstd)"); | ||
| goto fail; | ||
| } | ||
| out->nlayers++; |
There was a problem hiding this comment.
P2: Layer descriptor memory is leaked on post-parse validation failures because nlayers is incremented too late.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/oci/manifest.c, line 385:
<comment>Layer descriptor memory is leaked on post-parse validation failures because `nlayers` is incremented too late.</comment>
<file context>
@@ -0,0 +1,707 @@
+ set_parse_err(err_msg, "manifest layer entry is not an object");
+ goto fail;
+ }
+ if (parse_descriptor(desc, &out->layers[out->nlayers], err_msg) < 0)
+ goto fail;
+ oci_media_type_t lmt = out->layers[out->nlayers].media_type;
</file context>
| if (parse_descriptor(desc, &out->layers[out->nlayers], err_msg) < 0) | |
| goto fail; | |
| oci_media_type_t lmt = out->layers[out->nlayers].media_type; | |
| if (!oci_media_type_is_layer(lmt)) { | |
| set_parse_err(err_msg, | |
| "manifest layer has non-layer media type"); | |
| goto fail; | |
| } | |
| if (oci_media_type_is_foreign(lmt)) { | |
| set_parse_err(err_msg, | |
| "manifest references foreign (nondistributable) " | |
| "layer; not supported"); | |
| goto fail; | |
| } | |
| if (!oci_media_type_is_layer_supported(lmt)) { | |
| set_parse_err(err_msg, | |
| "manifest layer media type is not supported " | |
| "(only tar / tar+gzip / tar+zstd)"); | |
| goto fail; | |
| } | |
| out->nlayers++; | |
| oci_descriptor_t *slot = &out->layers[out->nlayers]; | |
| if (parse_descriptor(desc, slot, err_msg) < 0) | |
| goto fail; | |
| out->nlayers++; | |
| oci_media_type_t lmt = slot->media_type; | |
| if (!oci_media_type_is_layer(lmt)) { | |
| set_parse_err(err_msg, | |
| "manifest layer has non-layer media type"); | |
| goto fail; | |
| } | |
| if (oci_media_type_is_foreign(lmt)) { | |
| set_parse_err(err_msg, | |
| "manifest references foreign (nondistributable) " | |
| "layer; not supported"); | |
| goto fail; | |
| } | |
| if (!oci_media_type_is_layer_supported(lmt)) { | |
| set_parse_err(err_msg, | |
| "manifest layer media type is not supported " | |
| "(only tar / tar+gzip / tar+zstd)"); | |
| goto fail; | |
| } |
| *err_msg = type_msg; | ||
| return -1; | ||
| } | ||
| *out = item->valueint; |
There was a problem hiding this comment.
P2: schemaVersion parsing can accept fractional JSON numbers because valueint is used without an integer round-trip check.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/oci/manifest.c, line 295:
<comment>`schemaVersion` parsing can accept fractional JSON numbers because `valueint` is used without an integer round-trip check.</comment>
<file context>
@@ -0,0 +1,707 @@
+ *err_msg = type_msg;
+ return -1;
+ }
+ *out = item->valueint;
+ return 0;
+}
</file context>
This PR adds the elfuse oci subcommand and the pull/inspect halves of
Phase 1 of issue #31 (OCI image support). The seven commits split the
work along clean boundaries (reference parser, blob store, manifest
parser, HTTPS fetch, private-registry options, pull pipeline, inspect
renderer) and each carries its own unit-test target. Phase 2 (layer
unpack, clonefile copy-up, and elfuse run IMAGE) is intentionally not
part of this PR.
To test, try:
The implementation is hand-rolled in C alongside the existing codebase.
New third-party dependencies are libcurl (HTTPS, a system library on
macOS) and a vendored cJSON v1.7.18 under externals/cjson/ (a single
.c/.h pair, MIT). SHA-256/512 uses macOS CommonCrypto. There is no Go
or Rust toolchain dependency.
src/oci/ref.{c,h} parses image references in the Docker/OCI shorthand
forms (alpine:3.20, ghcr.io/owner/repo@sha256:..., registry-1.docker.io
/library/alpine), with lowercase enforcement on registry hosts and
digest hex strings. src/oci/cli.{c,h} dispatches the elfuse oci pull,
inspect, list, and prune subcommands and parses the shared CLI flags
(--store, -u, --insecure-ca, --insecure).
src/oci/digest.{c,h} wraps CommonCrypto with CC_LONG chunking so large
blobs streamed through it never exceed the underlying API's 32-bit
length limit. src/oci/blob-store.{c,h} implements the content-
addressable layout /blobs//, with atomic commit via
fsync + link(2) so two concurrent writers of the same digest dedupe
cleanly and a digest mismatch unlinks the staging file before
returning EINVAL.
src/oci/manifest.{c,h} parses image manifests, image indexes, and
image configs for both OCI and Docker media types. Every descriptor
digest is validated through oci_digest_parse so the blob-store
integration is direct. Foreign / nondistributable layers are rejected,
as are negative or fractional size fields. oci_index_pick_linux_arm64
prefers variant v8, then empty variant, then any other arm64 variant;
entries with unknown manifest media types are skipped because the
registry-fetch path cannot consume them. src/oci/media-type.{c,h}
canonicalizes OCI and Docker media-type strings.
src/oci/fetch.{c,h} is a libcurl-based HTTPS client with one CURL easy
handle and one cached bearer token per fetcher instance. On 401 it
parses the Www-Authenticate realm/service/scope, fetches a token, and
retries once with Authorization: Bearer. docker.io is rewritten to
registry-1.docker.io. Blobs are content-addressed end-to-end: short-
circuit on store has(), stream the body through oci_blob_writer with a
running-byte cap at descriptor size, and let the writer's commit
verify the digest. The 401-then-token-then-retry shape covers both
Docker Hub and GHCR public-pull subsets.
Private-registry options extend oci_fetcher_options_t with basic auth
(-u user:pass), --insecure-ca for a custom CA bundle, and
--insecure gated to loopback only (127.0.0.1 / localhost / ::1).
Insecure-on-non-loopback is rejected with EPERM before any byte hits
the wire. The test mock terminates TLS with a per-run self-signed RSA
cert generated via OpenSSL EVP, signed for CN=127.0.0.1 + SAN IP:
127.0.0.1 + DNS:localhost; libcurl is pinned to its LibreSSL backend
via curl_global_sslset so Secure Transport's CURLOPT_CAINFO short-
circuit does not silently pass negative ca_file cases. brew openssl@3
is the build dependency for the test mock (auto-detected in
mk/toolchain.mk; no new dependency for the release build).
src/oci/store.{c,h} extends the blob store with mutable tag pins at
/refs/// (algo:hex, written via fsync + atomic
rename). The default root is $XDG_DATA_HOME/elfuse/store or
$HOME/Library/Application Support/elfuse/store on macOS.
src/oci/pull.{c,h} drives the full pipeline: top-level manifest fetch
(by digest or tag), SHA-256 body cross-check against the
Docker-Content-Digest header (mismatch is a hostile-registry signal
that aborts before any blob write), persist manifest body, if index
then pick linux/arm64 and re-fetch the sub-manifest while the pin
still records the top-level digest, parse manifest, fetch config and
each layer through oci_fetch_blob, oci_store_put_ref. Re-pull short-
circuits blobs by content address. errno is preserved across the
cleanup goto so callers key off EPROTO / ENOENT / EINVAL.
src/oci/inspect.{c,h} renders the offline manifest tree. Default mode
for an index prints only the linux/arm64 entry tagged "[arm64]" and
and skips the drill. Pin miss prints a "run 'elfuse oci pull' first"
hint with rc=0 so the slice-1 inspect smoke is preserved. Partial-
store cases (index ok, sub-manifest missing) print the platform table
on stdout, a warning on stderr, and return rc=-1 errno=ENOENT.
Unit-test coverage: test-oci-ref (34 assertions), test-oci-digest
(25), test-oci-blob-store (14), test-oci-manifest (76 cases),
test-oci-fetch (15 offline cases plus opt-in test-oci-fetch-online),
test-oci-store (9), test-oci-pull (6), test-oci-inspect (6). All
native, no HVF, wired into make check. The fetch and pull suites
share an embedded TLS-terminated HTTP/1.1 mock under
tests/lib/oci-mock.{c,h}.
Explicit non-goals for Phase 1 through Phase 4: ORAS artifacts, the
distribution-spec referrers API, and sigstore/cosign signature
verification. The trust boundary for Phase 1 is TLS for transport,
content digest for storage, and tag-to-digest pinning at pull for
reproducibility. Users wanting supply-chain verification should run
cosign externally and pass the resolved digest to elfuse.
Summary by cubic
Add the
elfuse ocisubcommand with image pull and offline inspect, delivering Phase 1 of #31 (OCI image support). Pull verifies digests, stores blobs content‑addressed, and pins tags; inspect renders the manifest tree without network.New Features
elfuse oci pull|inspectwith--store,-u/--user,--insecure-ca,--insecure,-q.libcurl: anonymous + Bearer token, Basic auth, custom CA, loopback‑only insecure; blob streaming with size/sha checks;docker.io->registry-1.docker.io.refs/<reg>/<repo>/<tag>; default store under$XDG_DATA_HOME/elfuse/storeor$HOME/Library/Application Support/elfuse/store.Dependencies
libcurl.cJSONv1.7.18.openssl@3for the TLS mock server.Written for commit 0ec6b84. Summary will update on new commits. Review in cubic