fasttree-sys 0.2.0

Surowe bindingi FFI (extern "C") do biblioteki FastTree (Nim). Nie używaj bezpośrednio — patrz crate `fasttree`.
import std/[httpclient, json, strformat, os, strutils, uri]

type
  ImageRef* = object
    registry*, repository*, reference*: string  # reference = tag lub @sha256:...

  Layer* = object
    digest*, mediaType*: string
    size*: int64

  OciAuthError* = object of CatchableError

  AuthCache* = ref object
    ## Token Bearer, jeśli już zdobyty w ramach tego `pull` — przekazywany
    ## jawnie (nie chowany w polu HttpClient) właśnie DLATEGO, że klienci
    ## są teraz jednorazowe (patrz komentarz modułu): coś musi przetrwać
    ## między nimi, żeby nie robić handshake'u tokenu przed KAŻDYM blobem.
    token: string

proc newAuthCache*(): AuthCache = AuthCache(token: "")

proc registryScheme(registry: string): string =
  ## `http` tylko dla oczywistych lokalnych rejestrów deweloperskich
  ## (localhost/127.0.0.1[:port]) — dokładnie ta sama konwencja co Docker/
  ## containerd/buildah stosują domyślnie dla "insecure registries" bez
  ## dodatkowej konfiguracji. Każdy inny host (w tym IP w sieci lokalnej)
  ## dostaje `https` — nie robimy cichych wyjątków bezpieczeństwa poza tym
  ## jednym, powszechnie przyjętym przypadkiem.
  let host = registry.split(':')[0]
  if host == "localhost" or host == "127.0.0.1":
    "http"
  else:
    "https"

proc parseImageRef*(s: string): ImageRef =
  ## np. "ghcr.io/hackeros/system:v2.0" -> registry=ghcr.io, repo=hackeros/system, ref=v2.0
  let slashIdx = s.find('/')
  doAssert slashIdx > 0, "oczekiwano <registry>/<repo>[:tag]"
  let registry = s[0 ..< slashIdx]
  var rest = s[slashIdx+1 .. ^1]
  var reference = "latest"
  let colonIdx = rest.rfind(':')
  if colonIdx > rest.rfind('/'):
    reference = rest[colonIdx+1 .. ^1]
    rest = rest[0 ..< colonIdx]
  ImageRef(registry: registry, repository: rest, reference: reference)

proc parseWwwAuthenticate(header: string): tuple[realm, service, scope: string] =
  ## Parsuje `WWW-Authenticate: Bearer realm="...",service="...",scope="..."`.
  ## Zwraca puste stringi (nie wyjątek), jeśli nagłówek nie jest w tym
  ## formacie — wołający traktuje pusty realm jako "ten rejestr nie używa
  ## Bearer auth" i przerywa próbę autoryzacji.
  result = ("", "", "")
  if not header.startsWith("Bearer"):
    return
  let rest = header["Bearer".len .. ^1].strip()
  for part in rest.split(','):
    let kv = part.strip().split('=', 1)
    if kv.len != 2: continue
    let key = kv[0].strip()
    var val = kv[1].strip()
    if val.len >= 2 and val[0] == '"' and val[^1] == '"':
      val = val[1 ..< ^1]
    case key
    of "realm": result.realm = val
    of "service": result.service = val
    of "scope": result.scope = val

proc fetchBearerToken(realm, service, scope: string): string =
  var url = realm
  var params: seq[string] = @[]
  if service.len > 0: params.add "service=" & encodeUrl(service)
  if scope.len > 0: params.add "scope=" & encodeUrl(scope)
  if params.len > 0: url &= "?" & params.join("&")

  let tokenClient = newHttpClient(timeout = 15_000)
  defer: tokenClient.close()
  let raw = try:
      tokenClient.getContent(url)
    except CatchableError as e:
      raise newException(OciAuthError, "pobranie tokenu z '" & realm & "' nie powiodło się: " & e.msg)

  let j = parseJson(raw)
  # Rejestry różnią się nazwą pola: Docker Hub używa "token", niektóre inne
  # implementacje (m.in. starsze wersje distribution) — "access_token".
  if j.hasKey("token"): j["token"].getStr
  elif j.hasKey("access_token"): j["access_token"].getStr
  else: raise newException(OciAuthError, "odpowiedź serwera tokenów nie zawiera pola 'token' ani 'access_token'")

proc requestGet(url: string, auth: AuthCache): Response =
  ## Świeży klient, jedno żądanie. Jeśli `auth` ma już token z poprzedniego
  ## wywołania w ramach tego samego `pull`, wysyłamy go od razu (unikamy
  ## zbędnego roundtripu 401 dla KAŻDEGO bloba osobno).
  let client = newHttpClient(timeout = 30_000)
  defer: client.close()
  var headers = newHttpHeaders({
    "Accept": "application/vnd.oci.image.manifest.v1+json,application/vnd.docker.distribution.manifest.v2+json"
  })
  if auth.token.len > 0:
    headers["Authorization"] = "Bearer " & auth.token
  client.headers = headers
  result = client.request(url, HttpGet)

  if result.code == Http401:
    let wwwAuth = result.headers.getOrDefault("www-authenticate")
    let (realm, service, scope) = parseWwwAuthenticate(wwwAuth)
    if realm.len == 0:
      return  # 401 bez rozpoznawalnego Bearer challenge — nic więcej nie zrobimy
    auth.token = fetchBearerToken(realm, service, scope)
    let retryClient = newHttpClient(timeout = 30_000)
    defer: retryClient.close()
    retryClient.headers = newHttpHeaders({"Authorization": "Bearer " & auth.token})
    result = retryClient.request(url, HttpGet)

proc fetchManifest*(imgRef: ImageRef, auth: AuthCache): JsonNode =
  let url = &"{registryScheme(imgRef.registry)}://{imgRef.registry}/v2/{imgRef.repository}/manifests/{imgRef.reference}"
  let resp = requestGet(url, auth)
  if resp.code.is4xx or resp.code.is5xx:
    raise newException(OciAuthError,
      &"GET {url} zwróciło {resp.code} — {resp.body[0 ..< min(200, resp.body.len)]}")
  parseJson(resp.body)

proc layersFromManifest*(m: JsonNode): seq[Layer] =
  result = @[]
  for l in m["layers"]:
    result.add Layer(
      digest: l["digest"].getStr,
      mediaType: l["mediaType"].getStr,
      size: l["size"].getBiggestInt)

proc downloadLayer*(imgRef: ImageRef, layer: Layer, destPath: string, auth: AuthCache) =
  let url = &"{registryScheme(imgRef.registry)}://{imgRef.registry}/v2/{imgRef.repository}/blobs/{layer.digest}"
  createDir(destPath.parentDir)
  let resp = requestGet(url, auth)
  if resp.code.is4xx or resp.code.is5xx:
    raise newException(OciAuthError,
      &"GET {url} (blob {layer.digest}) zwróciło {resp.code}")
  writeFile(destPath, resp.body)

type ResolvedLayer* = object
  layer*: Layer
  path*: string        # ścieżka lokalna (cache), gotowa do `tar -xf`
  wasCached*: bool      # false = pobrana w tym wywołaniu, true = już była w cacheDir

proc digestToFilename(digest: string): string = digest.replace(":", "_")

proc resolveImageLayers*(imgRef: ImageRef, cacheDir: string): seq[ResolvedLayer] =
  ## Zwraca WSZYSTKIE warstwy obrazu, w kolejności z manifestu OCI (dół -> góra),
  ## z lokalną ścieżką każdej z nich. Warstwy już obecne w `cacheDir` (z
  ## poprzedniego `pull`, także innego taga współdzielącego bazę) nie są
  ## pobierane ponownie — to realizuje deduplikację na poziomie blobów OCI,
  ## niezależną od deduplikacji na poziomie chunków w store.nim.
  createDir(cacheDir)
  let auth = newAuthCache()
  let manifestJson = fetchManifest(imgRef, auth)
  let layers = layersFromManifest(manifestJson)

  result = @[]
  for layer in layers:
    let dest = cacheDir / digestToFilename(layer.digest)
    let cached = fileExists(dest)
    if not cached:
      downloadLayer(imgRef, layer, dest, auth)
    result.add ResolvedLayer(layer: layer, path: dest, wasCached: cached)