package engine // // ExperimentBuilder definition and implementation // import ( "errors" "fmt" "reflect" "strconv" "github.com/iancoleman/strcase" "github.com/ooni/probe-cli/v3/internal/model" ) // InputPolicy describes the experiment policy with respect to input. That is // whether it requires input, optionally accepts input, does not want input. type InputPolicy string const ( // InputOrQueryBackend indicates that the experiment requires // external input to run and that this kind of input is URLs // from the citizenlab/test-lists repository. If this input // not provided to the experiment, then the code that runs the // experiment is supposed to fetch from URLs from OONI's backends. InputOrQueryBackend = InputPolicy("or_query_backend") // InputStrictlyRequired indicates that the experiment // requires input and we currently don't have an API for // fetching such input. Therefore, either the user specifies // input or the experiment will fail for the lack of input. InputStrictlyRequired = InputPolicy("strictly_required") // InputOptional indicates that the experiment handles input, // if any; otherwise it fetchs input/uses a default. InputOptional = InputPolicy("optional") // InputNone indicates that the experiment does not want any // input and ignores the input if provided with it. InputNone = InputPolicy("none") // We gather input from StaticInput and SourceFiles. If there is // input, we return it. Otherwise, we return an internal static // list of inputs to be used with this experiment. InputOrStaticDefault = InputPolicy("or_static_default") ) // ExperimentBuilder builds an experiment. type ExperimentBuilder interface { // Interruptible tells you whether this is an interruptible experiment. This kind // of experiments (e.g. ndt7) may be interrupted mid way. Interruptible() bool // InputPolicy returns the experiment input policy. InputPolicy() InputPolicy // Options returns information about the experiment's options. Options() (map[string]OptionInfo, error) // SetOptionAny sets an option whose value is an any value. We will use reasonable // heuristics to convert the any value to the proper type of the field whose name is // contained by the key variable. If we cannot convert the provided any value to // the proper type, then this function returns an error. SetOptionAny(key string, value any) error // SetOptionsAny sets options from a map[string]any. See the documentation of // the SetOptionAny method for more information. SetOptionsAny(options map[string]any) error // SetCallbacks sets the experiment's interactive callbacks. SetCallbacks(callbacks model.ExperimentCallbacks) // NewExperiment creates the experiment instance. NewExperiment() Experiment } // experimentBuilder implements ExperimentBuilder. type experimentBuilder struct { // build is the constructor that build an experiment with the given config. build func(config interface{}) *experiment // callbacks contains callbacks for the new experiment. callbacks model.ExperimentCallbacks // config contains the experiment's config. config interface{} // inputPolicy contains the experiment's InputPolicy. inputPolicy InputPolicy // interruptible indicates whether the experiment is interruptible. interruptible bool } // Interruptible implements ExperimentBuilder.Interruptible. func (b *experimentBuilder) Interruptible() bool { return b.interruptible } // InputPolicy implements ExperimentBuilder.InputPolicy. func (b *experimentBuilder) InputPolicy() InputPolicy { return b.inputPolicy } // OptionInfo contains info about an option. type OptionInfo struct { // Doc contains the documentation. Doc string // Type contains the type. Type string } var ( // ErrConfigIsNotAStructPointer indicates we expected a pointer to struct. ErrConfigIsNotAStructPointer = errors.New("config is not a struct pointer") // ErrNoSuchField indicates there's no field with the given name. ErrNoSuchField = errors.New("no such field") // ErrCannotSetIntegerOption means SetOptionAny couldn't set an integer option. ErrCannotSetIntegerOption = errors.New("cannot set integer option") // ErrInvalidStringRepresentationOfBool indicates the string you passed // to SetOptionaAny is not a valid string representation of a bool. ErrInvalidStringRepresentationOfBool = errors.New("invalid string representation of bool") // ErrCannotSetBoolOption means SetOptionAny couldn't set a bool option. ErrCannotSetBoolOption = errors.New("cannot set bool option") // ErrCannotSetStringOption means SetOptionAny couldn't set a string option. ErrCannotSetStringOption = errors.New("cannot set string option") // ErrUnsupportedOptionType means we don't support the type passed to // the SetOptionAny method as an opaque any type. ErrUnsupportedOptionType = errors.New("unsupported option type") ) // Options implements ExperimentBuilder.Options. func (b *experimentBuilder) Options() (map[string]OptionInfo, error) { result := make(map[string]OptionInfo) ptrinfo := reflect.ValueOf(b.config) if ptrinfo.Kind() != reflect.Ptr { return nil, ErrConfigIsNotAStructPointer } structinfo := ptrinfo.Elem().Type() if structinfo.Kind() != reflect.Struct { return nil, ErrConfigIsNotAStructPointer } for i := 0; i < structinfo.NumField(); i++ { field := structinfo.Field(i) result[field.Name] = OptionInfo{ Doc: field.Tag.Get("ooni"), Type: field.Type.String(), } } return result, nil } // setOptionBool sets a bool option. func (b *experimentBuilder) setOptionBool(field reflect.Value, value any) error { switch v := value.(type) { case bool: field.SetBool(v) return nil case string: if v != "true" && v != "false" { return fmt.Errorf("%w: %s", ErrInvalidStringRepresentationOfBool, v) } field.SetBool(v == "true") return nil default: return fmt.Errorf("%w from a value of type %T", ErrCannotSetBoolOption, value) } } // setOptionInt sets an int option func (b *experimentBuilder) setOptionInt(field reflect.Value, value any) error { switch v := value.(type) { case int64: field.SetInt(v) return nil case int32: field.SetInt(int64(v)) return nil case int16: field.SetInt(int64(v)) return nil case int8: field.SetInt(int64(v)) return nil case int: field.SetInt(int64(v)) return nil case string: number, err := strconv.ParseInt(v, 10, 64) if err != nil { return fmt.Errorf("%w: %s", ErrCannotSetIntegerOption, err.Error()) } field.SetInt(number) return nil default: return fmt.Errorf("%w from a value of type %T", ErrCannotSetIntegerOption, value) } } // setOptionString sets a string option func (b *experimentBuilder) setOptionString(field reflect.Value, value any) error { switch v := value.(type) { case string: field.SetString(v) return nil default: return fmt.Errorf("%w from a value of type %T", ErrCannotSetStringOption, value) } } // SetOptionAny implements ExperimentBuilder.SetOptionAny. func (b *experimentBuilder) SetOptionAny(key string, value any) error { field, err := b.fieldbyname(b.config, key) if err != nil { return err } switch field.Kind() { case reflect.Int64: return b.setOptionInt(field, value) case reflect.Bool: return b.setOptionBool(field, value) case reflect.String: return b.setOptionString(field, value) default: return fmt.Errorf("%w: %T", ErrUnsupportedOptionType, value) } } // SetOptionsAny implements ExperimentBuilder.SetOptionsAny. func (b *experimentBuilder) SetOptionsAny(options map[string]any) error { for key, value := range options { if err := b.SetOptionAny(key, value); err != nil { return err } } return nil } // SetCallbacks implements ExperimentBuilder.SetCallbacks. func (b *experimentBuilder) SetCallbacks(callbacks model.ExperimentCallbacks) { b.callbacks = callbacks } // fieldbyname return v's field whose name is equal to the given key. func (b *experimentBuilder) fieldbyname(v interface{}, key string) (reflect.Value, error) { // See https://stackoverflow.com/a/6396678/4354461 ptrinfo := reflect.ValueOf(v) if ptrinfo.Kind() != reflect.Ptr { return reflect.Value{}, fmt.Errorf("%w but a %T", ErrConfigIsNotAStructPointer, v) } structinfo := ptrinfo.Elem() if structinfo.Kind() != reflect.Struct { return reflect.Value{}, fmt.Errorf("%w but a %T", ErrConfigIsNotAStructPointer, v) } field := structinfo.FieldByName(key) if !field.IsValid() || !field.CanSet() { return reflect.Value{}, fmt.Errorf("%w: %s", ErrNoSuchField, key) } return field, nil } // NewExperiment creates the experiment func (b *experimentBuilder) NewExperiment() Experiment { experiment := b.build(b.config) experiment.callbacks = b.callbacks return experiment } // canonicalizeExperimentName allows code to provide experiment names // in a more flexible way, where we have aliases. // // Because we allow for uppercase experiment names for backwards // compatibility with MK, we need to add some exceptions here when // mapping (e.g., DNSCheck => dnscheck). func canonicalizeExperimentName(name string) string { switch name = strcase.ToSnake(name); name { case "ndt_7": name = "ndt" // since 2020-03-18, we use ndt7 to implement ndt by default case "dns_check": name = "dnscheck" case "stun_reachability": name = "stunreachability" default: } return name } // newExperimentBuilder creates a new experimentBuilder instance. func newExperimentBuilder(session *Session, name string) (*experimentBuilder, error) { factory := experimentsByName[canonicalizeExperimentName(name)] if factory == nil { return nil, fmt.Errorf("no such experiment: %s", name) } builder := factory(session) builder.callbacks = model.NewPrinterCallbacks(session.Logger()) return builder, nil }