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