blob: 3007a1ae3283f72de07fe3c9b87768e04f5e316e [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 """
128 for optname in options:
129 if not field.get(optname):
130 self.error(model, field, "Required option '%s' is not present" % optname)
131
132 def check_modifier_consistent(self, model, field):
133 """ Validates that "modifier" is consistent with options.
Zack Williams5c2ea232019-01-30 15:23:01 -0700134
Scott Baker7ae3a8f2019-03-05 16:24:14 -0800135 Required/optional imply some settings for blank= and null=. These settings are dependent on the type
136 of field. See also jinja2_extensions/django.py which has to implement some of the same logic.
137 """
138 field_type = field["type"]
139 options = field.get("options", {})
140 modifier = options.get('modifier')
141 link_type = field.get("link_type")
142 mod_out = {}
143
144 if modifier == "required":
145 mod_out["blank"] = 'False'
146
147 if link_type != "manytomany":
148 mod_out["null"] = 'False'
149
150 elif modifier == "optional":
151 mod_out["blank"] = 'True'
152
153 # set defaults on link types
154 if link_type != "manytomany" and field_type != "bool":
155 mod_out["null"] = 'True'
156
157 else:
158 self.error(model, field, "Unknown modifier type '%s'" % modifier)
159
160 # print an error if there's a field conflict
161 for kmo in mod_out.keys():
162 if (kmo in options) and (options[kmo] != mod_out[kmo]):
Zack Williams5c2ea232019-01-30 15:23:01 -0700163 self.error(model, field, "Option `%s`=`%s` is inconsistent with modifier `%s`" %
164 (kmo, options[kmo], modifier))
Scott Baker7ae3a8f2019-03-05 16:24:14 -0800165
166 def validate_field_date(self, model, field):
167 self.check_modifier_consistent(model, field)
Zack Williams5c2ea232019-01-30 15:23:01 -0700168 self.allow_options(model, field,
169 ["auto_now_add", "blank", "db_index", "default",
170 "max_length", "modifier", "null", "content_type"])
Scott Baker7ae3a8f2019-03-05 16:24:14 -0800171
172 def validate_field_string(self, model, field):
173 # A string with a `content_type="date"` is actually a date
174 # TODO: Investigate why there are double-quotes around "date"
175 content_type = field.get("options", {}).get("content_type")
176 if content_type in ["\"date\""]:
177 self.validate_field_date(model, field)
178 return
179
180 # TODO: Investigate why there are double-quotes around the content types
181 if content_type and content_type not in ["\"stripped\"", "\"ip\"", "\"url\""]:
182 self.error(model, field, "Content type %s is not allowed" % content_type)
183
184 self.check_modifier_consistent(model, field)
185 self.allow_options(model, field,
Zack Williams5c2ea232019-01-30 15:23:01 -0700186 ["blank", "choices", "content_type", "db_index", "default",
187 "max_length", "modifier", "null", "varchar"])
Scott Baker7ae3a8f2019-03-05 16:24:14 -0800188
189 def validate_field_bool(self, model, field):
190 self.check_modifier_consistent(model, field)
191 self.allow_options(model, field, ["db_index", "default=True", "default=False", "modifier", "null=False"])
192 self.require_options(model, field, ["default"])
193
194 def validate_field_float(self, model, field):
195 self.check_modifier_consistent(model, field)
196 self.allow_options(model, field, ["blank", "db_index", "default", "modifier", "null"])
197
198 def validate_field_link_onetomany(self, model, field):
199 self.check_modifier_consistent(model, field)
200 self.allow_options(model, field,
201 ["blank", "db_index", "default", "model", "link_type=manytoone",
202 "modifier", "null", "port", "type=link"])
203
204 def validate_field_link_manytomany(self, model, field):
205 self.check_modifier_consistent(model, field)
206 self.allow_options(model, field,
207 ["blank", "db_index", "default", "model", "link_type=manytomany",
208 "modifier", "null", "port", "type=link"])
209
210 def validate_field_link(self, model, field):
Zack Williams5c2ea232019-01-30 15:23:01 -0700211 link_type = field.get("options", {}).get("link_type")
Scott Baker7ae3a8f2019-03-05 16:24:14 -0800212 if link_type == "manytoone":
213 self.validate_field_link_onetomany(model, field)
214 elif link_type == "manytomany":
215 self.validate_field_link_manytomany(model, field)
216 else:
217 self.error("Unknown link_type %s" % link_type)
218
219 def validate_field_integer(self, model, field):
220 # An integer with an option "type=link" is actually a link
221 if field.get("options", {}).get("type") == "link":
222 self.validate_field_link(model, field)
223 return
224
225 self.check_modifier_consistent(model, field)
Zack Williams5c2ea232019-01-30 15:23:01 -0700226 self.allow_options(model, field,
227 ["blank", "db_index", "default", "max_value", "min_value", "modifier", "null"])
Scott Baker7ae3a8f2019-03-05 16:24:14 -0800228
229 if self.is_option_true(field, "blank") and not self.is_option_true(field, "null"):
230 self.error(model, field, "If blank is true then null must also be true")
231
232 def validate_field(self, model, field):
233 if field["type"] == "string":
234 self.validate_field_string(model, field)
235 elif field["type"] in ["int32", "uint32"]:
236 self.validate_field_integer(model, field)
237 elif field["type"] == "float":
238 self.validate_field_float(model, field)
239 elif field["type"] == "bool":
240 self.validate_field_bool(model, field)
241 else:
242 self.error(model, field, "Unknown field type %s" % field["type"])
243
244 def validate_model(self, model):
245 for field in model["fields"]:
246 self.validate_field(model, field)
247
248 def validate(self):
249 """ Validate all models. This is the main entrypoint for validating xproto. """
250 for (name, model) in self.models.items():
Zack Williams5c2ea232019-01-30 15:23:01 -0700251 self.validate_model(model)