Alpine apk v2 package index, signature and dependency closur
OCaml 91%
8%
Dune 1%

README.md

apk #

The formats Alpine's package manager reads, in pure OCaml: the APKINDEX a repository publishes, the concatenated gzip streams a .apk file is, the RSA signature over the member that follows the first of them, and the dependency closure an install of a set of roots pulls in. No apk binary is run.

A tool that builds an Alpine root filesystem, or a mirror that serves one, needs to read the same metadata as apk-tools. apk-tools is written in C and expects an Alpine system; this library reads apk v2 metadata on any host that runs OCaml.

Installation #

Install with opam:

$ opam install apk

If opam cannot find the package, it has probably not reached the public opam-repository yet. Add the overlay repository, then install it:

$ opam repo add samoht https://tangled.org/gazagnaire.org/opam-overlay.git
$ opam update
$ opam install apk

Reading an index #

An APKINDEX is stanzas of <letter>:<value> lines separated by blank lines. A package keeps its stanza in the order it was read, so an index goes back out as it came in.

The stanza below is one package of a real index, C: first and a blank line after it. P: is the name, V: the version, A: the architecture and D: a dependency. C: is the checksum of the package's control stream (see Verifying an archive): the characters Q1, apk-tools' marker for a base64-encoded 20-byte hash, followed by the base64 of the SHA-1 of that stream's raw bytes.

C:Q1EyN5AdpAOBJWKMR89pp/C66o+OE=
P:busybox
V:1.37.0-r18
A:aarch64
D:so:libc.musl-aarch64.so.1
# let index =
    "C:Q1EyN5AdpAOBJWKMR89pp/C66o+OE=\nP:busybox\nV:1.37.0-r18\nA:aarch64\nD:so:libc.musl-aarch64.so.1\n\n";;
val index : string =
  "C:Q1EyN5AdpAOBJWKMR89pp/C66o+OE=\nP:busybox\nV:1.37.0-r18\nA:aarch64\nD:so:libc.musl-aarch64.so.1\n\n"
# let packages = Apk.Index.packages (Apk.Index.of_string_exn index);;
val packages : Apk.Index.pkg list = [<abstr>]
# List.map Apk.Index.name packages;;
- : string list = ["busybox"]
# List.map (fun p -> Option.value ~default:"" (Apk.Index.arch p)) packages;;
- : string list = ["aarch64"]
# Apk.Index.to_string (Apk.Index.of_string_exn index) = index;;
- : bool = true

Resolving a closure #

Closure.One_provider is what a host installing a root filesystem itself takes: one package per dependency name. Closure.Every_provider is what a guest's own apk may ask a mirror for: every provider of a name, closed under the index's install-if entries. Several packages may provide one name (a shared-library so: name or a virtual package), and each is a provider of it. An install-if entry (i: in the index) says a package is installed automatically as soon as every package it lists is installed: below, extra joins once lib and app are both in.

# let index =
    Apk.Index.of_string_exn
      "P:app\nV:1\nD:lib\n\nP:lib\nV:1\n\nP:extra\nV:1\ni:lib app\n\n";;
val index : Apk.Index.t = <abstr>
# let roots = [ Apk.Dep.of_string_exn "app" ];;
val roots : Apk.Dep.t list = [<abstr>]
# let names breadth =
    Apk.Closure.resolve breadth (Apk.Index.packages index) ~roots
    |> Result.map (List.map Apk.Index.name);;
val names : Apk.Closure.breadth -> (string list, [> `Msg of string ]) result =
  <fun>
# names Apk.Closure.One_provider;;
- : (string list, [> `Msg of string ]) result = Ok ["app"; "lib"]
# names Apk.Closure.Every_provider;;
- : (string list, [> `Msg of string ]) result = Ok ["app"; "extra"; "lib"]

Verifying an archive #

An apk v2 package is three gzip streams one after the other: a signature stream holding one file .SIGN.RSA.<keyid> (or .SIGN.RSA256.), a control stream holding .PKGINFO, and a data stream with the package's files. An APKINDEX.tar.gz opens the same way, with the index in its second stream. Archive.signed splits the first two streams and hands back the raw bytes of the second. The signature is RSA PKCS#1 v1.5 over the SHA-1 (RSA), SHA-256 (RSA256) or SHA-512 (RSA512) of those raw, still-compressed bytes. The data stream is covered only indirectly: .PKGINFO records its SHA-256 as datahash, which apk-tools checks on install and this library does not check. Signature.verify checks that signature against the key it names in a directory of trusted keys, /etc/apk/keys on a real system.

# let verified ~keys_dir archive =
    match Apk.Archive.signed archive with
    | Error _ as e -> e
    | Ok (signature, control) ->
        Apk.Signature.verify ~keys_dir
          ~signature:(Apk.Archive.data signature)
          ~signed:(Apk.Archive.raw control)
        |> Result.map (fun () -> Apk.Archive.data control);;
val verified :
  keys_dir:Fpath.t -> string -> (string, [> `Msg of string ]) result = <fun>
# verified ~keys_dir:(Fpath.v "/etc/apk/keys") "not an archive at all";;
- : (string, [> `Msg of string ]) result =
Error (`Msg "malformed gzip member: Invalid GZip header")

The index a repository publishes also vouches for each package's control member: Archive.control_checksum spells the C: value a stanza carries.

Standards #

  • apk-v2(5): the archive, its three sections, and the .SIGN.<algorithm>.<keyid> signature members.
  • apk-package(5): the metadata fields, each named by its v3 name, its .PKGINFO name and its index character.
  • apk-keys(5): the directory a signature's key name is resolved in.

The index and closure test vectors are apk-tools' own solver test data, taken verbatim.

apk-tools is the reference implementation and is C. abuild writes the archives it reads. No OCaml library implements either format: opam carries no apk package, and this library builds on the existing OCaml implementations of the layers underneath (tar, gzip and RSA PKCS#1) instead of reimplementing them. It reads v2 only; the v3 ADB format that apk-tools 3 introduces is a different encoding and is not handled here.

Licence #

ISC, see LICENSE.md.