blob: d95c5d72be4868df8f753f9c46e669b0d82b6a32 [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",
32 "feedback_state", "unique", "unique_with"]
Scott Baker7ae3a8f2019-03-05 16:24:14 -080033
34# Options that must be either "True" or "False"
Scott Baker08d10402019-04-08 16:19:59 -070035BOOLEAN_OPTIONS = ["blank", "db_index", "feedback_state", "gui_hidden", "null", "tosca_key", "unique", "text"]
Scott Baker7ae3a8f2019-03-05 16:24:14 -080036
Zack Williams5c2ea232019-01-30 15:23:01 -070037
Scott Baker7ae3a8f2019-03-05 16:24:14 -080038class 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 Baker08d10402019-04-08 16:19:59 -070048 def error(self, model, field, message, severity="ERROR"):
Scott Baker7ae3a8f2019-03-05 16:24:14 -080049 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 Williams5c2ea232019-01-30 15:23:01 -070059 if start_line > error_first_line_number:
Scott Baker7ae3a8f2019-03-05 16:24:14 -080060 break
61 error_filename = fn
62 error_line_offset = start_line
63
Scott Baker08d10402019-04-08 16:19:59 -070064 self.errors.append({"severity": severity,
65 "model": model,
Scott Baker7ae3a8f2019-03-05 16:24:14 -080066 "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 Baker08d10402019-04-08 16:19:59 -070073 def warning(self, *args, **kwargs):
74 self.error(*args, severity="WARNING", **kwargs)
75
Scott Baker7ae3a8f2019-03-05 16:24:14 -080076 def print_errors(self):
77 # Sort by line number
Zack Williams5c2ea232019-01-30 15:23:01 -070078 for error in sorted(self.errors, key=lambda error: error["absolute_line_number"]):
Scott Baker7ae3a8f2019-03-05 16:24:14 -080079 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 Baker08d10402019-04-08 16:19:59 -070090 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 Baker7ae3a8f2019-03-05 16:24:14 -080097
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 Williams5c2ea232019-01-30 15:23:01 -0700118 if optname == k and optval == v:
Scott Baker7ae3a8f2019-03-05 16:24:14 -0800119 allowed = True
120 else:
Zack Williams5c2ea232019-01-30 15:23:01 -0700121 if option == k:
Scott Baker7ae3a8f2019-03-05 16:24:14 -0800122 allowed = True
123
124 if not allowed:
Zack Williams5c2ea232019-01-30 15:23:01 -0700125 self.error(model, field, "Option %s=%s is not allowed" % (k, v))
Scott Baker7ae3a8f2019-03-05 16:24:14 -0800126
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 Bakerc80304a2019-03-07 11:07:29 -0800133 options = field.get("options", {})
Scott Baker7ae3a8f2019-03-05 16:24:14 -0800134 for optname in options:
Scott Bakerc80304a2019-03-07 11:07:29 -0800135 if optname not in options:
Scott Baker7ae3a8f2019-03-05 16:24:14 -0800136 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 Williams5c2ea232019-01-30 15:23:01 -0700140
Scott Baker7ae3a8f2019-03-05 16:24:14 -0800141 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 Williams5c2ea232019-01-30 15:23:01 -0700169 self.error(model, field, "Option `%s`=`%s` is inconsistent with modifier `%s`" %
170 (kmo, options[kmo], modifier))
Scott Baker7ae3a8f2019-03-05 16:24:14 -0800171
172 def validate_field_date(self, model, field):
173 self.check_modifier_consistent(model, field)
Zack Williams5c2ea232019-01-30 15:23:01 -0700174 self.allow_options(model, field,
175 ["auto_now_add", "blank", "db_index", "default",
176 "max_length", "modifier", "null", "content_type"])
Scott Baker7ae3a8f2019-03-05 16:24:14 -0800177
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 Williams5c2ea232019-01-30 15:23:01 -0700192 ["blank", "choices", "content_type", "db_index", "default",
Scott Baker08d10402019-04-08 16:19:59 -0700193 "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 Baker7ae3a8f2019-03-05 16:24:14 -0800217
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 Williams5c2ea232019-01-30 15:23:01 -0700240 link_type = field.get("options", {}).get("link_type")
Scott Baker7ae3a8f2019-03-05 16:24:14 -0800241 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 Baker6cd253f2019-03-13 15:36:49 -0700246 self.error(model, field, "Unknown link_type %s" % link_type)
Scott Baker7ae3a8f2019-03-05 16:24:14 -0800247
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 Williams5c2ea232019-01-30 15:23:01 -0700255 self.allow_options(model, field,
256 ["blank", "db_index", "default", "max_value", "min_value", "modifier", "null"])
Scott Baker7ae3a8f2019-03-05 16:24:14 -0800257
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 Williams5c2ea232019-01-30 15:23:01 -0700280 self.validate_model(model)