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