1package engine 2 3import ( 4 "errors" 5 "fmt" 6 "reflect" 7 "regexp" 8 "strconv" 9 10 "github.com/iancoleman/strcase" 11 "github.com/ooni/probe-engine/model" 12) 13 14// InputPolicy describes the experiment policy with respect to input. That is 15// whether it requires input, optionally accepts input, does not want input. 16type InputPolicy string 17 18const ( 19 // InputOrQueryTestLists indicates that the experiment requires 20 // external input to run and that this kind of input is URLs 21 // from the citizenlab/test-lists repository. If this input 22 // not provided to the experiment, then the code that runs the 23 // experiment is supposed to fetch from URLs from OONI's backends. 24 InputOrQueryTestLists = InputPolicy("or_query_test_lists") 25 26 // InputStrictlyRequired indicates that the experiment 27 // requires input and we currently don't have an API for 28 // fetching such input. Therefore, either the user specifies 29 // input or the experiment will fail for the lack of input. 30 InputStrictlyRequired = InputPolicy("strictly_required") 31 32 // InputOptional indicates that the experiment handles input, 33 // if any; otherwise it fetchs input/uses a default. 34 InputOptional = InputPolicy("optional") 35 36 // InputNone indicates that the experiment does not want any 37 // input and ignores the input if provided with it. 38 InputNone = InputPolicy("none") 39) 40 41// ExperimentBuilder is an experiment builder. 42type ExperimentBuilder struct { 43 build func(interface{}) *Experiment 44 callbacks model.ExperimentCallbacks 45 config interface{} 46 inputPolicy InputPolicy 47 interruptible bool 48} 49 50// Interruptible tells you whether this is an interruptible experiment. This kind 51// of experiments (e.g. ndt7) may be interrupted mid way. 52func (b *ExperimentBuilder) Interruptible() bool { 53 return b.interruptible 54} 55 56// InputPolicy returns the experiment input policy 57func (b *ExperimentBuilder) InputPolicy() InputPolicy { 58 return b.inputPolicy 59} 60 61// OptionInfo contains info about an option 62type OptionInfo struct { 63 Doc string 64 Type string 65} 66 67// Options returns info about all options 68func (b *ExperimentBuilder) Options() (map[string]OptionInfo, error) { 69 result := make(map[string]OptionInfo) 70 ptrinfo := reflect.ValueOf(b.config) 71 if ptrinfo.Kind() != reflect.Ptr { 72 return nil, errors.New("config is not a pointer") 73 } 74 structinfo := ptrinfo.Elem().Type() 75 if structinfo.Kind() != reflect.Struct { 76 return nil, errors.New("config is not a struct") 77 } 78 for i := 0; i < structinfo.NumField(); i++ { 79 field := structinfo.Field(i) 80 result[field.Name] = OptionInfo{ 81 Doc: field.Tag.Get("ooni"), 82 Type: field.Type.String(), 83 } 84 } 85 return result, nil 86} 87 88// SetOptionBool sets a bool option 89func (b *ExperimentBuilder) SetOptionBool(key string, value bool) error { 90 field, err := fieldbyname(b.config, key) 91 if err != nil { 92 return err 93 } 94 if field.Kind() != reflect.Bool { 95 return errors.New("field is not a bool") 96 } 97 field.SetBool(value) 98 return nil 99} 100 101// SetOptionInt sets an int option 102func (b *ExperimentBuilder) SetOptionInt(key string, value int64) error { 103 field, err := fieldbyname(b.config, key) 104 if err != nil { 105 return err 106 } 107 if field.Kind() != reflect.Int64 { 108 return errors.New("field is not an int64") 109 } 110 field.SetInt(value) 111 return nil 112} 113 114// SetOptionString sets a string option 115func (b *ExperimentBuilder) SetOptionString(key, value string) error { 116 field, err := fieldbyname(b.config, key) 117 if err != nil { 118 return err 119 } 120 if field.Kind() != reflect.String { 121 return errors.New("field is not a string") 122 } 123 field.SetString(value) 124 return nil 125} 126 127var intregexp = regexp.MustCompile("^[0-9]+$") 128 129// SetOptionGuessType sets an option whose type depends on the 130// option value. If the value is `"true"` or `"false"` we 131// assume the option is boolean. If the value is numeric, then we 132// set an integer option. Otherwise we set a string option. 133func (b *ExperimentBuilder) SetOptionGuessType(key, value string) error { 134 if value == "true" || value == "false" { 135 return b.SetOptionBool(key, value == "true") 136 } 137 if !intregexp.MatchString(value) { 138 return b.SetOptionString(key, value) 139 } 140 number, _ := strconv.ParseInt(value, 10, 64) 141 return b.SetOptionInt(key, number) 142} 143 144// SetOptionsGuessType calls the SetOptionGuessType method for every 145// key, value pair contained by the opts input map. 146func (b *ExperimentBuilder) SetOptionsGuessType(opts map[string]string) error { 147 for k, v := range opts { 148 if err := b.SetOptionGuessType(k, v); err != nil { 149 return err 150 } 151 } 152 return nil 153} 154 155// SetCallbacks sets the interactive callbacks 156func (b *ExperimentBuilder) SetCallbacks(callbacks model.ExperimentCallbacks) { 157 b.callbacks = callbacks 158} 159 160func fieldbyname(v interface{}, key string) (reflect.Value, error) { 161 // See https://stackoverflow.com/a/6396678/4354461 162 ptrinfo := reflect.ValueOf(v) 163 if ptrinfo.Kind() != reflect.Ptr { 164 return reflect.Value{}, errors.New("value is not a pointer") 165 } 166 structinfo := ptrinfo.Elem() 167 if structinfo.Kind() != reflect.Struct { 168 return reflect.Value{}, errors.New("value is not a pointer to struct") 169 } 170 field := structinfo.FieldByName(key) 171 if !field.IsValid() || !field.CanSet() { 172 return reflect.Value{}, errors.New("no such field") 173 } 174 return field, nil 175} 176 177// NewExperiment creates the experiment 178func (b *ExperimentBuilder) NewExperiment() *Experiment { 179 experiment := b.build(b.config) 180 experiment.callbacks = b.callbacks 181 return experiment 182} 183 184// canonicalizeExperimentName allows code to provide experiment names 185// in a more flexible way, where we have aliases. 186func canonicalizeExperimentName(name string) string { 187 switch name = strcase.ToSnake(name); name { 188 case "ndt_7": 189 name = "ndt" // since 2020-03-18, we use ndt7 to implement ndt by default 190 default: 191 } 192 return name 193} 194 195func newExperimentBuilder(session *Session, name string) (*ExperimentBuilder, error) { 196 factory, _ := experimentsByName[canonicalizeExperimentName(name)] 197 if factory == nil { 198 return nil, fmt.Errorf("no such experiment: %s", name) 199 } 200 builder := factory(session) 201 builder.callbacks = model.NewPrinterCallbacks(session.Logger()) 202 return builder, nil 203} 204