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