blob: 48f0fb59d35e2de617e2190897e7628d88a50099 [file] [log] [blame]
Scott Baker7ae3a8f2019-03-05 16:24:14 -08001# 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 Baker7ae3a8f2019-03-05 16:24:14 -080025from __future__ import print_function
26import sys
27import os
28
29
30# Options that are always allowed
Zack Williams5c2ea232019-01-30 15:23:01 -070031COMMON_OPTIONS = ["help_text", "gui_hidden", "tosca_key", "tosca_key_one_of",
Scott Baker44476852019-05-02 11:44:26 -070032 "bookkeeping_state", "feedback_state", "unique", "unique_with"]
Scott Baker7ae3a8f2019-03-05 16:24:14 -080033
34# Options that must be either "True" or "False"
Scott Baker44476852019-05-02 11:44:26 -070035BOOLEAN_OPTIONS = ["blank", "db_index", "bookkeeping_state", "feedback_state", "gui_hidden", "null",
36 "tosca_key", "unique", "text"]
Scott Baker7ae3a8f2019-03-05 16:24:14 -080037
Zack Williams5c2ea232019-01-30 15:23:01 -070038
Scott Baker7ae3a8f2019-03-05 16:24:14 -080039class 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 Baker08d10402019-04-08 16:19:59 -070049 def error(self, model, field, message, severity="ERROR"):
Scott Baker7ae3a8f2019-03-05 16:24:14 -080050 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 Williams5c2ea232019-01-30 15:23:01 -070060 if start_line > error_first_line_number:
Scott Baker7ae3a8f2019-03-05 16:24:14 -080061 break
62 error_filename = fn
63 error_line_offset = start_line
64
Scott Baker08d10402019-04-08 16:19:59 -070065 self.errors.append({"severity": severity,
66 "model": model,
Scott Baker7ae3a8f2019-03-05 16:24:14 -080067 "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 Baker08d10402019-04-08 16:19:59 -070074 def warning(self, *args, **kwargs):
75 self.error(*args, severity="WARNING", **kwargs)
76
Scott Baker7ae3a8f2019-03-05 16:24:14 -080077 def print_errors(self):
78 # Sort by line number
Zack Williams5c2ea232019-01-30 15:23:01 -070079 for error in sorted(self.errors, key=lambda error: error["absolute_line_number"]):
Scott Baker7ae3a8f2019-03-05 16:24:14 -080080 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 Baker08d10402019-04-08 16:19:59 -070091 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 Baker7ae3a8f2019-03-05 16:24:14 -080098
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 Williams5c2ea232019-01-30 15:23:01 -0700119 if optname == k and optval == v:
Scott Baker7ae3a8f2019-03-05 16:24:14 -0800120 allowed = True
121 else:
Zack Williams5c2ea232019-01-30 15:23:01 -0700122 if option == k:
Scott Baker7ae3a8f2019-03-05 16:24:14 -0800123 allowed = True
124
125 if not allowed:
Zack Williams5c2ea232019-01-30 15:23:01 -0700126 self.error(model, field, "Option %s=%s is not allowed" % (k, v))
Scott Baker7ae3a8f2019-03-05 16:24:14 -0800127
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 Bakerc80304a2019-03-07 11:07:29 -0800134 options = field.get("options", {})
Scott Baker7ae3a8f2019-03-05 16:24:14 -0800135 for optname in options:
Scott Bakerc80304a2019-03-07 11:07:29 -0800136 if optname not in options:
Scott Baker7ae3a8f2019-03-05 16:24:14 -0800137 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 Williams5c2ea232019-01-30 15:23:01 -0700141
Scott Baker7ae3a8f2019-03-05 16:24:14 -0800142 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 Williams5c2ea232019-01-30 15:23:01 -0700170 self.error(model, field, "Option `%s`=`%s` is inconsistent with modifier `%s`" %
171 (kmo, options[kmo], modifier))
Scott Baker7ae3a8f2019-03-05 16:24:14 -0800172
173 def validate_field_date(self, model, field):
174 self.check_modifier_consistent(model, field)
Zack Williams5c2ea232019-01-30 15:23:01 -0700175 self.allow_options(model, field,
176 ["auto_now_add", "blank", "db_index", "default",
177 "max_length", "modifier", "null", "content_type"])
Scott Baker7ae3a8f2019-03-05 16:24:14 -0800178
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 Williams5c2ea232019-01-30 15:23:01 -0700193 ["blank", "choices", "content_type", "db_index", "default",
Scott Baker08d10402019-04-08 16:19:59 -0700194 "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 Baker7ae3a8f2019-03-05 16:24:14 -0800218
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 Williams5c2ea232019-01-30 15:23:01 -0700241 link_type = field.get("options", {}).get("link_type")
Scott Baker7ae3a8f2019-03-05 16:24:14 -0800242 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 Baker6cd253f2019-03-13 15:36:49 -0700247 self.error(model, field, "Unknown link_type %s" % link_type)
Scott Baker7ae3a8f2019-03-05 16:24:14 -0800248
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 Williams5c2ea232019-01-30 15:23:01 -0700256 self.allow_options(model, field,
257 ["blank", "db_index", "default", "max_value", "min_value", "modifier", "null"])
Scott Baker7ae3a8f2019-03-05 16:24:14 -0800258
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 Williams5c2ea232019-01-30 15:23:01 -0700281 self.validate_model(model)