Scott Baker | 7ae3a8f | 2019-03-05 16:24:14 -0800 | [diff] [blame] | 1 | # Copyright 2017-present Open Networking Foundation |
| 2 | # |
| 3 | # Licensed under the Apache License, Version 2.0 (the "License"); |
| 4 | # you may not use this file except in compliance with the License. |
| 5 | # You may obtain a copy of the License at |
| 6 | # |
| 7 | # http://www.apache.org/licenses/LICENSE-2.0 |
| 8 | # |
| 9 | # Unless required by applicable law or agreed to in writing, software |
| 10 | # distributed under the License is distributed on an "AS IS" BASIS, |
| 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 12 | # See the License for the specific language governing permissions and |
| 13 | # limitations under the License. |
| 14 | |
| 15 | """ |
| 16 | This module is used to validate xproto models and fields. The basic guiding principle is everything that isn't |
| 17 | specifically allowed here should be denied by default. |
| 18 | |
| 19 | Note: While xproto must maintain some compatibility with django give the implementation choice of using django |
| 20 | in the core, it's the case that the allowable set of xproto options may be a subset of what is allowed under |
| 21 | django. For example, there may be django features that do not need exposure in xproto and/or are incompatible |
| 22 | with other design aspects of XOS such as the XOS gRPC API implementation. |
| 23 | """ |
| 24 | |
Scott Baker | 7ae3a8f | 2019-03-05 16:24:14 -0800 | [diff] [blame] | 25 | from __future__ import print_function |
| 26 | import sys |
| 27 | import os |
| 28 | |
| 29 | |
| 30 | # Options that are always allowed |
Zack Williams | 5c2ea23 | 2019-01-30 15:23:01 -0700 | [diff] [blame] | 31 | COMMON_OPTIONS = ["help_text", "gui_hidden", "tosca_key", "tosca_key_one_of", |
Scott Baker | 4447685 | 2019-05-02 11:44:26 -0700 | [diff] [blame] | 32 | "bookkeeping_state", "feedback_state", "unique", "unique_with"] |
Scott Baker | 7ae3a8f | 2019-03-05 16:24:14 -0800 | [diff] [blame] | 33 | |
| 34 | # Options that must be either "True" or "False" |
Scott Baker | 4447685 | 2019-05-02 11:44:26 -0700 | [diff] [blame] | 35 | BOOLEAN_OPTIONS = ["blank", "db_index", "bookkeeping_state", "feedback_state", "gui_hidden", "null", |
| 36 | "tosca_key", "unique", "text"] |
Scott Baker | 7ae3a8f | 2019-03-05 16:24:14 -0800 | [diff] [blame] | 37 | |
Zack Williams | 5c2ea23 | 2019-01-30 15:23:01 -0700 | [diff] [blame] | 38 | |
Scott Baker | 7ae3a8f | 2019-03-05 16:24:14 -0800 | [diff] [blame] | 39 | class XProtoValidator(object): |
| 40 | def __init__(self, models, line_map): |
| 41 | """ |
| 42 | models: a list of model definitions. Each model is a dictionary. |
| 43 | line_map: a list of tuples (start_line_no, filename) that tells which file goes with which line number. |
| 44 | """ |
| 45 | self.models = models |
| 46 | self.line_map = line_map |
| 47 | self.errors = [] |
| 48 | |
Scott Baker | 08d1040 | 2019-04-08 16:19:59 -0700 | [diff] [blame] | 49 | def error(self, model, field, message, severity="ERROR"): |
Scott Baker | 7ae3a8f | 2019-03-05 16:24:14 -0800 | [diff] [blame] | 50 | if field and field.get("_linespan"): |
| 51 | error_first_line_number = field["_linespan"][0] |
| 52 | error_last_line_number = field["_linespan"][1] |
| 53 | else: |
| 54 | error_first_line_number = model["_linespan"][0] |
| 55 | error_last_line_number = model["_linespan"][1] |
| 56 | |
| 57 | error_filename = "unknown" |
| 58 | error_line_offset = 0 |
| 59 | for (start_line, fn) in self.line_map: |
Zack Williams | 5c2ea23 | 2019-01-30 15:23:01 -0700 | [diff] [blame] | 60 | if start_line > error_first_line_number: |
Scott Baker | 7ae3a8f | 2019-03-05 16:24:14 -0800 | [diff] [blame] | 61 | break |
| 62 | error_filename = fn |
| 63 | error_line_offset = start_line |
| 64 | |
Scott Baker | 08d1040 | 2019-04-08 16:19:59 -0700 | [diff] [blame] | 65 | self.errors.append({"severity": severity, |
| 66 | "model": model, |
Scott Baker | 7ae3a8f | 2019-03-05 16:24:14 -0800 | [diff] [blame] | 67 | "field": field, |
| 68 | "message": message, |
| 69 | "filename": error_filename, |
| 70 | "first_line_number": error_first_line_number - error_line_offset, |
| 71 | "last_line_number": error_last_line_number - error_line_offset, |
| 72 | "absolute_line_number": error_first_line_number}) |
| 73 | |
Scott Baker | 08d1040 | 2019-04-08 16:19:59 -0700 | [diff] [blame] | 74 | def warning(self, *args, **kwargs): |
| 75 | self.error(*args, severity="WARNING", **kwargs) |
| 76 | |
Scott Baker | 7ae3a8f | 2019-03-05 16:24:14 -0800 | [diff] [blame] | 77 | def print_errors(self): |
| 78 | # Sort by line number |
Zack Williams | 5c2ea23 | 2019-01-30 15:23:01 -0700 | [diff] [blame] | 79 | for error in sorted(self.errors, key=lambda error: error["absolute_line_number"]): |
Scott Baker | 7ae3a8f | 2019-03-05 16:24:14 -0800 | [diff] [blame] | 80 | model = error["model"] |
| 81 | field = error["field"] |
| 82 | message = error["message"] |
| 83 | first_line_number = error["first_line_number"] |
| 84 | last_line_number = error["last_line_number"] |
| 85 | |
| 86 | if first_line_number != last_line_number: |
| 87 | linestr = "%d-%d" % (first_line_number, last_line_number) |
| 88 | else: |
| 89 | linestr = "%d" % first_line_number |
| 90 | |
Scott Baker | 08d1040 | 2019-04-08 16:19:59 -0700 | [diff] [blame] | 91 | print("[%s] %s:%s %s.%s (Type %s): %s" % (error["severity"], |
| 92 | os.path.basename(error["filename"]), |
| 93 | linestr, |
| 94 | model.get("name"), |
| 95 | field.get("name"), |
| 96 | field.get("type"), |
| 97 | message), file=sys.stderr) |
Scott Baker | 7ae3a8f | 2019-03-05 16:24:14 -0800 | [diff] [blame] | 98 | |
| 99 | def is_option_true(self, field, name): |
| 100 | options = field.get("options") |
| 101 | if not options: |
| 102 | return False |
| 103 | option = options.get(name) |
| 104 | return option == "True" |
| 105 | |
| 106 | def allow_options(self, model, field, options): |
| 107 | """ Only allow the options specified in `options`. If some option is present that isn't in allowed, then |
| 108 | register an error. |
| 109 | |
| 110 | `options` is a list of options which can either be simple names, or `name=value`. |
| 111 | """ |
| 112 | options = COMMON_OPTIONS + options |
| 113 | |
| 114 | for (k, v) in field.get("options", {}).items(): |
| 115 | allowed = False |
| 116 | for option in options: |
| 117 | if "=" in option: |
| 118 | (optname, optval) = option.split("=") |
Zack Williams | 5c2ea23 | 2019-01-30 15:23:01 -0700 | [diff] [blame] | 119 | if optname == k and optval == v: |
Scott Baker | 7ae3a8f | 2019-03-05 16:24:14 -0800 | [diff] [blame] | 120 | allowed = True |
| 121 | else: |
Zack Williams | 5c2ea23 | 2019-01-30 15:23:01 -0700 | [diff] [blame] | 122 | if option == k: |
Scott Baker | 7ae3a8f | 2019-03-05 16:24:14 -0800 | [diff] [blame] | 123 | allowed = True |
| 124 | |
| 125 | if not allowed: |
Zack Williams | 5c2ea23 | 2019-01-30 15:23:01 -0700 | [diff] [blame] | 126 | self.error(model, field, "Option %s=%s is not allowed" % (k, v)) |
Scott Baker | 7ae3a8f | 2019-03-05 16:24:14 -0800 | [diff] [blame] | 127 | |
| 128 | if k in BOOLEAN_OPTIONS and (v not in ["True", "False"]): |
| 129 | self.error(model, field, "Option `%s` must be either True or False, but is '%s'" % (k, v)) |
| 130 | |
| 131 | def require_options(self, model, field, options): |
| 132 | """ Require an option to be present. |
| 133 | """ |
Scott Baker | c80304a | 2019-03-07 11:07:29 -0800 | [diff] [blame] | 134 | options = field.get("options", {}) |
Scott Baker | 7ae3a8f | 2019-03-05 16:24:14 -0800 | [diff] [blame] | 135 | for optname in options: |
Scott Baker | c80304a | 2019-03-07 11:07:29 -0800 | [diff] [blame] | 136 | if optname not in options: |
Scott Baker | 7ae3a8f | 2019-03-05 16:24:14 -0800 | [diff] [blame] | 137 | self.error(model, field, "Required option '%s' is not present" % optname) |
| 138 | |
| 139 | def check_modifier_consistent(self, model, field): |
| 140 | """ Validates that "modifier" is consistent with options. |
Zack Williams | 5c2ea23 | 2019-01-30 15:23:01 -0700 | [diff] [blame] | 141 | |
Scott Baker | 7ae3a8f | 2019-03-05 16:24:14 -0800 | [diff] [blame] | 142 | Required/optional imply some settings for blank= and null=. These settings are dependent on the type |
| 143 | of field. See also jinja2_extensions/django.py which has to implement some of the same logic. |
| 144 | """ |
| 145 | field_type = field["type"] |
| 146 | options = field.get("options", {}) |
| 147 | modifier = options.get('modifier') |
| 148 | link_type = field.get("link_type") |
| 149 | mod_out = {} |
| 150 | |
| 151 | if modifier == "required": |
| 152 | mod_out["blank"] = 'False' |
| 153 | |
| 154 | if link_type != "manytomany": |
| 155 | mod_out["null"] = 'False' |
| 156 | |
| 157 | elif modifier == "optional": |
| 158 | mod_out["blank"] = 'True' |
| 159 | |
| 160 | # set defaults on link types |
| 161 | if link_type != "manytomany" and field_type != "bool": |
| 162 | mod_out["null"] = 'True' |
| 163 | |
| 164 | else: |
| 165 | self.error(model, field, "Unknown modifier type '%s'" % modifier) |
| 166 | |
| 167 | # print an error if there's a field conflict |
| 168 | for kmo in mod_out.keys(): |
| 169 | if (kmo in options) and (options[kmo] != mod_out[kmo]): |
Zack Williams | 5c2ea23 | 2019-01-30 15:23:01 -0700 | [diff] [blame] | 170 | self.error(model, field, "Option `%s`=`%s` is inconsistent with modifier `%s`" % |
| 171 | (kmo, options[kmo], modifier)) |
Scott Baker | 7ae3a8f | 2019-03-05 16:24:14 -0800 | [diff] [blame] | 172 | |
| 173 | def validate_field_date(self, model, field): |
| 174 | self.check_modifier_consistent(model, field) |
Zack Williams | 5c2ea23 | 2019-01-30 15:23:01 -0700 | [diff] [blame] | 175 | self.allow_options(model, field, |
| 176 | ["auto_now_add", "blank", "db_index", "default", |
| 177 | "max_length", "modifier", "null", "content_type"]) |
Scott Baker | 7ae3a8f | 2019-03-05 16:24:14 -0800 | [diff] [blame] | 178 | |
| 179 | def validate_field_string(self, model, field): |
| 180 | # A string with a `content_type="date"` is actually a date |
| 181 | # TODO: Investigate why there are double-quotes around "date" |
| 182 | content_type = field.get("options", {}).get("content_type") |
| 183 | if content_type in ["\"date\""]: |
| 184 | self.validate_field_date(model, field) |
| 185 | return |
| 186 | |
| 187 | # TODO: Investigate why there are double-quotes around the content types |
| 188 | if content_type and content_type not in ["\"stripped\"", "\"ip\"", "\"url\""]: |
| 189 | self.error(model, field, "Content type %s is not allowed" % content_type) |
| 190 | |
| 191 | self.check_modifier_consistent(model, field) |
| 192 | self.allow_options(model, field, |
Zack Williams | 5c2ea23 | 2019-01-30 15:23:01 -0700 | [diff] [blame] | 193 | ["blank", "choices", "content_type", "db_index", "default", |
Scott Baker | 08d1040 | 2019-04-08 16:19:59 -0700 | [diff] [blame] | 194 | "max_length", "modifier", "null", "text"]) |
| 195 | |
| 196 | # max_length is a mandatory argument of CharField. |
| 197 | if (content_type in [None]) and \ |
| 198 | (not self.is_option_true(field, "text")) and \ |
| 199 | ("max_length" not in field["options"]): |
| 200 | self.error(model, field, "String field should have a max_length or text=True") |
| 201 | |
| 202 | if "max_length" in field["options"]: |
| 203 | max_length = field["options"]["max_length"] |
| 204 | try: |
| 205 | max_length = int(max_length) |
| 206 | if (max_length == 0): |
| 207 | self.error(model, field, "max_length should not be zero") |
| 208 | |
| 209 | if 0 < abs(256-max_length) < 3: |
| 210 | self.warning(model, field, |
| 211 | "max_length of %s is close to suggested max_length of 256" % max_length) |
| 212 | |
| 213 | if 0 < abs(1024-max_length) < 3: |
| 214 | self.warning(model, field, |
| 215 | "max_length of %s is close to suggested max_length of 1024" % max_length) |
| 216 | except ValueError: |
| 217 | self.error(model, field, "max_length must be a number") |
Scott Baker | 7ae3a8f | 2019-03-05 16:24:14 -0800 | [diff] [blame] | 218 | |
| 219 | def validate_field_bool(self, model, field): |
| 220 | self.check_modifier_consistent(model, field) |
| 221 | self.allow_options(model, field, ["db_index", "default=True", "default=False", "modifier", "null=False"]) |
| 222 | self.require_options(model, field, ["default"]) |
| 223 | |
| 224 | def validate_field_float(self, model, field): |
| 225 | self.check_modifier_consistent(model, field) |
| 226 | self.allow_options(model, field, ["blank", "db_index", "default", "modifier", "null"]) |
| 227 | |
| 228 | def validate_field_link_onetomany(self, model, field): |
| 229 | self.check_modifier_consistent(model, field) |
| 230 | self.allow_options(model, field, |
| 231 | ["blank", "db_index", "default", "model", "link_type=manytoone", |
| 232 | "modifier", "null", "port", "type=link"]) |
| 233 | |
| 234 | def validate_field_link_manytomany(self, model, field): |
| 235 | self.check_modifier_consistent(model, field) |
| 236 | self.allow_options(model, field, |
| 237 | ["blank", "db_index", "default", "model", "link_type=manytomany", |
| 238 | "modifier", "null", "port", "type=link"]) |
| 239 | |
| 240 | def validate_field_link(self, model, field): |
Zack Williams | 5c2ea23 | 2019-01-30 15:23:01 -0700 | [diff] [blame] | 241 | link_type = field.get("options", {}).get("link_type") |
Scott Baker | 7ae3a8f | 2019-03-05 16:24:14 -0800 | [diff] [blame] | 242 | if link_type == "manytoone": |
| 243 | self.validate_field_link_onetomany(model, field) |
| 244 | elif link_type == "manytomany": |
| 245 | self.validate_field_link_manytomany(model, field) |
| 246 | else: |
Scott Baker | 6cd253f | 2019-03-13 15:36:49 -0700 | [diff] [blame] | 247 | self.error(model, field, "Unknown link_type %s" % link_type) |
Scott Baker | 7ae3a8f | 2019-03-05 16:24:14 -0800 | [diff] [blame] | 248 | |
| 249 | def validate_field_integer(self, model, field): |
| 250 | # An integer with an option "type=link" is actually a link |
| 251 | if field.get("options", {}).get("type") == "link": |
| 252 | self.validate_field_link(model, field) |
| 253 | return |
| 254 | |
| 255 | self.check_modifier_consistent(model, field) |
Zack Williams | 5c2ea23 | 2019-01-30 15:23:01 -0700 | [diff] [blame] | 256 | self.allow_options(model, field, |
| 257 | ["blank", "db_index", "default", "max_value", "min_value", "modifier", "null"]) |
Scott Baker | 7ae3a8f | 2019-03-05 16:24:14 -0800 | [diff] [blame] | 258 | |
| 259 | if self.is_option_true(field, "blank") and not self.is_option_true(field, "null"): |
| 260 | self.error(model, field, "If blank is true then null must also be true") |
| 261 | |
| 262 | def validate_field(self, model, field): |
| 263 | if field["type"] == "string": |
| 264 | self.validate_field_string(model, field) |
| 265 | elif field["type"] in ["int32", "uint32"]: |
| 266 | self.validate_field_integer(model, field) |
| 267 | elif field["type"] == "float": |
| 268 | self.validate_field_float(model, field) |
| 269 | elif field["type"] == "bool": |
| 270 | self.validate_field_bool(model, field) |
| 271 | else: |
| 272 | self.error(model, field, "Unknown field type %s" % field["type"]) |
| 273 | |
| 274 | def validate_model(self, model): |
| 275 | for field in model["fields"]: |
| 276 | self.validate_field(model, field) |
| 277 | |
| 278 | def validate(self): |
| 279 | """ Validate all models. This is the main entrypoint for validating xproto. """ |
| 280 | for (name, model) in self.models.items(): |
Zack Williams | 5c2ea23 | 2019-01-30 15:23:01 -0700 | [diff] [blame] | 281 | self.validate_model(model) |