blob: bf73b84f4208c791ed99e12924144b2d60758159 [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"
35BOOLEAN_OPTIONS = ["blank", "db_index", "feedback_state", "gui_hidden", "null", "tosca_key", "unique", "varchar"]
36
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
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 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
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 Williams5c2ea232019-01-30 15:23:01 -070074 for error in sorted(self.errors, key=lambda error: error["absolute_line_number"]):
Scott Baker7ae3a8f2019-03-05 16:24:14 -080075 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 Williams5c2ea232019-01-30 15:23:01 -070087 linestr,
88 model.get("name"),
89 field.get("name"),
90 field.get("type"),
91 message), file=sys.stderr)
Scott Baker7ae3a8f2019-03-05 16:24:14 -080092
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 Williams5c2ea232019-01-30 15:23:01 -0700113 if optname == k and optval == v:
Scott Baker7ae3a8f2019-03-05 16:24:14 -0800114 allowed = True
115 else:
Zack Williams5c2ea232019-01-30 15:23:01 -0700116 if option == k:
Scott Baker7ae3a8f2019-03-05 16:24:14 -0800117 allowed = True
118
119 if not allowed:
Zack Williams5c2ea232019-01-30 15:23:01 -0700120 self.error(model, field, "Option %s=%s is not allowed" % (k, v))
Scott Baker7ae3a8f2019-03-05 16:24:14 -0800121
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 Bakerc80304a2019-03-07 11:07:29 -0800128 options = field.get("options", {})
Scott Baker7ae3a8f2019-03-05 16:24:14 -0800129 for optname in options:
Scott Bakerc80304a2019-03-07 11:07:29 -0800130 if optname not in options:
Scott Baker7ae3a8f2019-03-05 16:24:14 -0800131 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 Williams5c2ea232019-01-30 15:23:01 -0700135
Scott Baker7ae3a8f2019-03-05 16:24:14 -0800136 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 Williams5c2ea232019-01-30 15:23:01 -0700164 self.error(model, field, "Option `%s`=`%s` is inconsistent with modifier `%s`" %
165 (kmo, options[kmo], modifier))
Scott Baker7ae3a8f2019-03-05 16:24:14 -0800166
167 def validate_field_date(self, model, field):
168 self.check_modifier_consistent(model, field)
Zack Williams5c2ea232019-01-30 15:23:01 -0700169 self.allow_options(model, field,
170 ["auto_now_add", "blank", "db_index", "default",
171 "max_length", "modifier", "null", "content_type"])
Scott Baker7ae3a8f2019-03-05 16:24:14 -0800172
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 Williams5c2ea232019-01-30 15:23:01 -0700187 ["blank", "choices", "content_type", "db_index", "default",
188 "max_length", "modifier", "null", "varchar"])
Scott Baker7ae3a8f2019-03-05 16:24:14 -0800189
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 Williams5c2ea232019-01-30 15:23:01 -0700212 link_type = field.get("options", {}).get("link_type")
Scott Baker7ae3a8f2019-03-05 16:24:14 -0800213 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 Baker6cd253f2019-03-13 15:36:49 -0700218 self.error(model, field, "Unknown link_type %s" % link_type)
Scott Baker7ae3a8f2019-03-05 16:24:14 -0800219
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 Williams5c2ea232019-01-30 15:23:01 -0700227 self.allow_options(model, field,
228 ["blank", "db_index", "default", "max_value", "min_value", "modifier", "null"])
Scott Baker7ae3a8f2019-03-05 16:24:14 -0800229
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 Williams5c2ea232019-01-30 15:23:01 -0700252 self.validate_model(model)