Tavus

En struct som inte går att förfalska

Elixir har inga privata structs. Så vi vaktar AST:n i stället, med ett test som planterar sitt eget fel.

I Revelius finns en liggare där varje åtagande får ett betyg: uppnått, ej uppnått eller vet ej. Regeln är enkel att säga och svår att hålla. Betyget får bara sättas av en verifierad körning, aldrig av den som gjorde jobbet. Inte av en agent, inte av en människa, inte av en hjälpfunktion som råkar ha rätt behörighet.

I Ash betyder det en policy på aktören. Den som skapar ett betyg måste vara en %Korare{}, en struct som säger att en viss kontroll kördes på en viss commit och gav ett visst utfall.

Hålet

Problemet är att Elixir inte kan göra en struct privat. Den som kan skriva alias Revelius.Liggaren.Korare kan också skriva det här, var som helst i kodbasen:

actor = %Korare{
  repo: "plattform",
  sha: "abc123",
  korning_id: "1",
  slutsats: :success,
  verifierad_at: DateTime.utc_now()
}

@enforce_keys hjälper inte, nycklarna finns ju där. @opaque gäller bara för Dialyzer. Och en map med korning: true är samma hål med ett annat namn. Vi hade sett det förut: ett tidigare system tog domen direkt från anroparen, och då blev ingenting någonsin underkänt.

En konstruktör

Steg ett är att structen byggs på exakt ett ställe. Korning.verifiera/1 hämtar körningen ur källan, läser slutsatsen där och bygger structen. Svarar källan med något oväntat blir slutsatsen :okand.

def varde(%Korare{slutsats: :success}), do: :uppnadd
def varde(%Korare{slutsats: :failure}), do: :ej_uppnadd
def varde(%Korare{}), do: :vet_ej

Betygets värde härleds ur structen. Anroparen kan inte påstå det. All annan kod frågar is_struct(actor, Korare) och bygger aldrig en egen.

En vakt som läser koden, inte texten

Steg två är att göra regeln strukturell. Ett test läser varje fil under lib/ och test/, gör om den till AST med Code.string_to_quoted/2 och letar med Macro.prewalk/3:

Macro.prewalk(ast, [], fn
  {:%, meta, [{:__aliases__, _, delar}, _]} = nod, acc when is_list(delar) ->
    if List.last(delar) == :Korare,
      do: {nod, [{fil, meta[:line], "struct-literal Korare"} | acc]},
      else: {nod, acc}

  {:%{}, meta, par} = nod, acc when is_list(par) ->
    if {:korning, true} in par,
      do: {nod, [{fil, meta[:line], "korning: true"} | acc]},
      else: {nod, acc}

  nod, acc ->
    {nod, acc}
end)

Två filer är undantagna: konstruktören och testfabriken, som i sin tur går via verifiera/1. Allt annat fäller sviten med fil och rad.

Varför AST och inte grep? För att en sökning på text träffar dokumentationen, missar en literal som är bruten över tre rader och bryr sig inte om hur aliaset är skrivet. Det är koden som vaktas, inte orden om den.

Vakten måste bita

En vakt som aldrig fäller något går inte att skilja från en vakt som inte fungerar. Därför har testet en positiv kontroll: det planterar en fil med en struct-literal och en flagga, kör samma genomgång och kräver att båda hittas på rätt rad. Slutar vakten fungera, till exempel för att AST-formen ändras i en ny Elixir-version, så är det det testet som går rött, inte tystnaden.

Vad den inte gör

Vakten stoppar inte någon som verkligen vill runt den. struct/2 med modulen i en variabel tar sig förbi. Den är ett skydd mot genvägar och misstag, sådana som en agent gärna tar när ett test är rött och klockan går. Mot den som medvetet bryter regeln skyddar granskningen. Men det vanliga felet, det som annars hade hänt en tisdag eftermiddag, kan inte längre ens kompilera sig förbi CI.

Ett betyg som lämnas in är en åsikt. Ett betyg som räknas fram är ett faktum.

Skrivet av Tavus Sweden AB, som bygger Revelius i Elixir. Fler texter.