Raw File
test_fuzzing_light.ml
(*****************************************************************************)
(*                                                                           *)
(* Open Source License                                                       *)
(* Copyright (c) 2021 Nomadic Labs <contact@nomadic-labs.com>                *)
(*                                                                           *)
(* Permission is hereby granted, free of charge, to any person obtaining a   *)
(* copy of this software and associated documentation files (the "Software"),*)
(* to deal in the Software without restriction, including without limitation *)
(* the rights to use, copy, modify, merge, publish, distribute, sublicense,  *)
(* and/or sell copies of the Software, and to permit persons to whom the     *)
(* Software is furnished to do so, subject to the following conditions:      *)
(*                                                                           *)
(* The above copyright notice and this permission notice shall be included   *)
(* in all copies or substantial portions of the Software.                    *)
(*                                                                           *)
(* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR*)
(* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,  *)
(* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL   *)
(* THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER*)
(* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING   *)
(* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER       *)
(* DEALINGS IN THE SOFTWARE.                                                 *)
(*                                                                           *)
(*****************************************************************************)

(** Testing

    -------
    Component:    Client
    Invocation:   dune build @src/lib_proxy/runtest
    Dependencies: src/lib_proxy/test/light_lib.ml
                  src/lib_proxy/test/test_light.ml
    Description:  Most generators in this module are recursive / nested, hence
                  width and depth of structures is fine-tuned.
*)

module Store = Tezos_proxy.Local_context
module Proof = Tezos_context_sigs.Context.Proof_types
open Lib_test.Qcheck2_helpers

open Tezos_shell_services_test_helpers.Shell_services_test_helpers

(** [list1_gen gen] generates non-empty lists using [gen]. *)
let list1_gen gen =
  QCheck2.Gen.(
    list_size (1 -- 20) gen |> add_shrink_invariant (fun l -> l <> []))

let irmin_tree_gen =
  let module StringList = struct
    type t = string list

    let compare = Stdlib.compare
  end in
  let module StringListMap = Stdlib.Map.Make (StringList) in
  let open MakeMapGen (StringListMap) in
  let open QCheck2.Gen in
  let+ entries = small_list (pair (small_list string) bytes_gen) in
  List.fold_left_s
    (fun built_tree (path, bytes) -> Store.Tree.add built_tree path bytes)
    (Store.Tree.empty Store.empty)
    entries
  |> Lwt_main.run

let print_tree = Format.asprintf "%a" Store.Tree.pp

module HashStability = struct
  let make_tree_shallow repo tree =
    let hash = Store.Tree.hash tree in
    let data =
      match Store.Tree.kind tree with
      | `Value -> `Value hash
      | `Tree -> `Node hash
    in
    Store.Tree.shallow repo data

  (** Sub-par pseudo-random shallower, based on the tree and sub-trees hashes.
      The resulting tree may or may not be shallowed (i.e. exactly the same as
      the input one). *)
  let rec make_partial_shallow_tree repo tree =
    let open Lwt_syntax in
    if (Store.Tree.hash tree |> Context_hash.hash) mod 2 = 0 then
      (* Full shallow *)
      Lwt.return @@ make_tree_shallow repo tree
    else
      (* Maybe shallow some sub-trees *)
      let* dir = Store.Tree.list tree [] in
      Lwt_list.fold_left_s
        (fun wip_tree (key, sub_tree) ->
          let* partial_shallowed_sub_tree =
            make_partial_shallow_tree repo sub_tree
          in
          Store.Tree.add_tree wip_tree [key] partial_shallowed_sub_tree)
        tree
        dir

  (** Provides a tree and a potentially shallowed (partially, totally or not at all) equivalent tree.
      Randomization of shallowing is sub-par (based on tree hash) because
      otherwise it would be very difficult to provide shrinking. Note that
      this will no be a problem once QCheck provides integrated shrinking. *)
  let tree_and_shallow_gen =
    let open QCheck2.Gen in
    let repo = Lwt_main.run (Store.Tree.make_repo ()) in
    let+ tree = irmin_tree_gen in
    (tree, Lwt_main.run (make_partial_shallow_tree repo tree))

  let print_tree_and_shallow = QCheck2.Print.pair print_tree print_tree

  (** Test that replacing Irmin subtrees by their [Store.Tree.shallow]
      value leaves the top-level [Store.Tree.hash] unchanged.

      This test was also proposed to Irmin in
      https://github.com/mirage/irmin/pull/1291 *)
  let test_hash_stability =
    let open QCheck2 in
    Test.make
      ~name:"Shallowing trees does not change their top-level hash"
      ~print:print_tree_and_shallow
      tree_and_shallow_gen
    @@ fun (tree, shallow_tree) ->
    let hash = Store.Tree.hash tree in
    let shallow_hash = Store.Tree.hash shallow_tree in
    if Context_hash.equal hash shallow_hash then true
    else
      Test.fail_reportf
        "@[<v 2>Equality check failed!@,\
         expected:@,\
         %a@,\
         actual:@,\
         %a@,\
         expected hash:@,\
         %a@,\
         actual hash:@,\
         %a@]"
        Store.Tree.pp
        tree
        Store.Tree.pp
        shallow_tree
        Context_hash.pp
        hash
        Context_hash.pp
        shallow_hash
end

let check_tree_eq = qcheck_eq ~pp:Store.Tree.pp ~eq:Store.Tree.equal

module AddTree = struct
  (** Test that getting a tree that was just set returns this tree.

      This test was also proposed to Irmin in
      https://github.com/mirage/irmin/pull/1291 *)
  let test_add_tree =
    let open QCheck2 in
    Test.make
      ~name:
        "let tree' = Store.Tree.add_tree tree key at_key in \
         Store.Tree.find_tree tree' key = at_key"
      ~print:
        Print.(
          triple HashStability.print_tree_and_shallow (list string) print_tree)
      Gen.(
        triple
          HashStability.tree_and_shallow_gen
          (list1_gen string)
          irmin_tree_gen)
      (fun ( ((_, tree) : _ * Store.tree),
             (key : Store.key),
             (added : Store.tree) ) ->
        let tree' = Store.Tree.add_tree tree key added |> Lwt_main.run in
        let tree_opt_set_at_key =
          Store.Tree.find_tree tree' key |> Lwt_main.run
        in
        match tree_opt_set_at_key with
        | None -> check_tree_eq (Store.Tree.empty Store.empty) added
        | Some tree_set_at_key -> check_tree_eq added tree_set_at_key)
end

module Consensus = struct
  let chain, block = (`Main, `Head 0)

  class mock_rpc_context : RPC_context.simple =
    object
      method call_service
          : 'm 'p 'q 'i 'o.
            (([< Resto.meth] as 'm), unit, 'p, 'q, 'i, 'o) RPC_service.t ->
            'p ->
            'q ->
            'i ->
            'o tzresult Lwt.t =
        assert false
    end

  let mk_rogue_tree (mproof : Proof.tree Proof.t) (seed : int list) :
      (Proof.tree Proof.t, string) result =
    let tree_proof_eq = Proof.Internal_for_tests.tree_proof_eq in
    let rec gen_rec ~rand attempts_left =
      if attempts_left = 0 then Error "mk_rogue_tree: giving up"
      else
        let gen = merkle_proof_gen in
        let generated, _, _ = QCheck2.Gen.generate1 ~rand gen in
        if tree_proof_eq mproof generated then gen_rec ~rand (attempts_left - 1)
        else Ok generated
    in
    let rand = Random.State.make (Array.of_list seed) in
    gen_rec ~rand 128

  (* [mock_light_rpc mproof [(endpoint1, true); (endpoint2, false)] seed]
     returns an instance of [Tezos_proxy.Light_proto.PROTO_RPCS]
     that always returns a rogue (illegal) variant of [mproof] when querying [endpoint1],
     [mproof] when querying [endpoint2], and [None] otherwise *)
  let mock_light_rpc mproof endpoints_and_rogueness seed =
    (module struct
      (** Use physical equality on [rpc_context] because they are identical objects. *)
      let merkle_tree (pgi : Tezos_proxy.Proxy.proxy_getter_input) _ _ =
        List.assq pgi.rpc_context endpoints_and_rogueness
        |> Option.map (fun is_rogue ->
               if is_rogue then
                 match mk_rogue_tree mproof seed with
                 | Ok rogue_mtree -> rogue_mtree
                 | _ -> QCheck2.assume_fail ()
               else mproof)
        |> Lwt.return_ok
    end : Tezos_proxy.Light_proto.PROTO_RPCS)

  let mock_printer () =
    let rev_logs : string list ref = ref [] in
    object
      inherit
        Tezos_client_base.Client_context.simple_printer
          (fun _channel log ->
            rev_logs := log :: !rev_logs ;
            Lwt.return_unit)

      method get_logs = List.rev !rev_logs
    end

  (* used for debugging *)
  let _print_keys l =
    let l = List.map (fun s -> "\"" ^ s ^ "\"") l in
    "[" ^ String.concat "; " l ^ "]"

  (** [test_consensus min_agreement nb_honest nb_rogue key mproof randoms consensus_expected]
      checks that a consensus run with [nb_honest] honest nodes (i.e. that return [mproof] when requesting [key]),
      [nb_rogue] rogue nodes (i.e. that falsify data with the [mk_rogue_*] functions when requesting [key])
      returns [consensus_expected]. [randoms] is used to inject randomness in the rogue behaviour. *)
  let test_consensus min_agreement nb_honest nb_rogue key mproof randoms
      consensus_expected =
    let open Lwt_syntax in
    assert (nb_honest >= 0) ;
    assert (nb_rogue >= 0) ;
    let honests = List.repeat nb_honest false in
    let rogues = List.repeat nb_rogue true in
    let endpoints_and_rogueness =
      List.map
        (fun is_rogue -> (new mock_rpc_context, is_rogue))
        (honests @ rogues)
    in
    let (module Light_proto) =
      mock_light_rpc mproof endpoints_and_rogueness randoms
    in
    let module Consensus = Tezos_proxy.Light_consensus.Make (Light_proto) in
    let printer = mock_printer () in
    let input : Tezos_proxy.Light_consensus.input =
      {
        printer = (printer :> Tezos_client_base.Client_context.printer);
        min_agreement;
        chain;
        block;
        key;
        mproof;
      }
    in
    let validating_endpoints =
      List.mapi
        (fun n (endpoint, _is_rogue) ->
          let uri = Printf.sprintf "http://foobar:%d" n |> Uri.of_string in
          (uri, endpoint))
        endpoints_and_rogueness
    in
    let+ consensus_reached = Consensus.consensus input validating_endpoints in
    qcheck_eq ~pp:Format.pp_print_bool consensus_expected consensus_reached
end

let add_test_consensus (min_agreement, honest, rogue, consensus_expected) =
  let open QCheck2 in
  (* Because the node providing data always agrees, [honest] must be > 0 *)
  assert (honest > 0) ;
  (* Because we test consensus, to which the node providing data
     doesn't participate: *)
  let honest = honest - 1 in
  Test.make
    ~name:
      (Printf.sprintf
         "min_agreement=%f, honest=%d, rogue=%d consensus_expected=%b"
         min_agreement
         honest
         rogue
         consensus_expected)
    ~print:Print.(pair print_merkle_proof (list int))
    Gen.(pair merkle_proof_gen (small_list int))
  @@ fun ((mproof, _, key), randoms) ->
  Consensus.test_consensus
    min_agreement
    honest
    rogue
    key
    mproof
    randoms
    consensus_expected
  |> Lwt_main.run

let test_consensus_spec =
  let open QCheck2 in
  let open Gen in
  let min_agreement_gen = 0 -- 100 in
  let honest_gen = 1 -- 1000 in
  let rogue_gen = 0 -- 1000 in
  Test.make
    ~name:
      "test_consensus min_agreement honest rogue ... = min_agreeing_endpoints \
       min_agreement (honest + rogue + 1) <= honest"
    ~print:
      Print.(pair (triple int int int) (pair print_merkle_proof (list int)))
    (pair
       (triple min_agreement_gen honest_gen rogue_gen)
       (pair merkle_proof_gen (small_list int)))
  @@ fun ((min_agreement_int, honest, rogue), ((mproof, _, key), seed)) ->
  assert (0 <= min_agreement_int && min_agreement_int <= 100) ;
  let min_agreement = Float.of_int min_agreement_int /. 100. in
  assert (0.0 <= min_agreement && min_agreement <= 1.0) ;
  assert (0 < honest && honest <= 1024) ;
  assert (0 <= rogue && rogue <= 1024) ;
  let consensus_expected =
    (* +1 because there's the endpoint providing data, which always agrees *)
    let honest = honest + 1 in
    let nb_endpoints = honest + rogue in
    honest
    >= Tezos_proxy.Light_consensus.min_agreeing_endpoints
         min_agreement
         nb_endpoints
  in
  Consensus.test_consensus
    min_agreement
    honest
    rogue
    key
    mproof
    seed
    consensus_expected
  |> Lwt_main.run

let () =
  Alcotest.run
    "Mode Light"
    [
      ( "Hash stability",
        qcheck_wrap [HashStability.test_hash_stability; AddTree.test_add_tree]
      );
      ( "Consensus consistency examples",
        (* These tests are kinda superseded by the fuzzing tests
           ([test_consensus_spec]) below. However, I want to keep them for
           documentation purposes, because they provide examples. In addition,
           if tests break in the future, these ones will be easier to
           debug than the most general ones. *)
        qcheck_wrap ~rand:(Random.State.make [|348980449|])
        @@ List.map
             add_test_consensus
             [
               (* min_agreement, nb honest nodes, nb rogue nodes, consensus expected *)
               (1.0, 2, 0, true);
               (1.0, 3, 0, true);
               (1.0, 4, 0, true);
               (1.0, 2, 1, false);
               (* Next one should fail because 3*0.7 |> ceil == 3 whereas only 2 nodes agree *)
               (0.7, 2, 1, false);
               (0.7, 1, 2, false);
               (0.7, 1, 3, false);
               (0.01, 1, 1, true);
               (0.01, 1, 2, true);
               (* Passes because 0.01 *. (1 + 99) |> ceil == 1 and the node providing data is always there *)
               (0.01, 1, 99, true);
               (* But then 0.01 *. (1 + 100) |> ceil == 2: *)
               (0.01, 1, 100, false);
               (0.6, 2, 1, true);
               (0.6, 3, 1, true);
               (0.6, 4, 1, true);
               (0.6, 5, 1, true);
               (0.5, 2, 2, true);
               (0.01, 1, 2, true);
             ] );
      ("Consensus consistency", qcheck_wrap [test_consensus_spec]);
    ]
back to top