No Description
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

validator.py 16KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491
  1. #
  2. # This file is part of Lodel 2 (https://github.com/OpenEdition)
  3. #
  4. # Copyright (C) 2015-2017 Cléo UMS-3287
  5. #
  6. # This program is free software: you can redistribute it and/or modify
  7. # it under the terms of the GNU Affero General Public License as published
  8. # by the Free Software Foundation, either version 3 of the License, or
  9. # (at your option) any later version.
  10. #
  11. # This program is distributed in the hope that it will be useful,
  12. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  13. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  14. # GNU Affero General Public License for more details.
  15. #
  16. # You should have received a copy of the GNU Affero General Public License
  17. # along with this program. If not, see <http://www.gnu.org/licenses/>.
  18. #
  19. import sys
  20. import os.path
  21. import re
  22. import socket
  23. import inspect
  24. import copy
  25. from lodel.context import LodelContext
  26. LodelContext.expose_modules(globals(), {
  27. 'lodel.mlnamedobject.mlnamedobject': ['MlNamedObject'],
  28. 'lodel.exceptions': ['LodelException', 'LodelExceptions',
  29. 'LodelFatalError', 'FieldValidationError']})
  30. ##
  31. # @package lodel.settings.validator Lodel2 settings validators/cast module.
  32. #
  33. # Validator are registered in the Validator class.
  34. # @note to get a list of registered default validators just run
  35. # <pre>$ python scripts/settings_validator.py</pre>
  36. #
  37. # @remarks Should we reconsider specifying conf right in this module?
  38. ##
  39. # @brief Exception class that should be raised when a validation fails
  40. class ValidationError(Exception):
  41. pass
  42. ##
  43. # @brief Handles settings validators
  44. #
  45. # Class instance are callable objects that takes a value argument (the value
  46. # to validate). It raises a ValidationError if validation fails, else it returns
  47. # a properly cast value.
  48. #
  49. #@todo Implement an IP validator for use in the multisite confspec
  50. class Validator(MlNamedObject):
  51. _validators = dict()
  52. _description = dict()
  53. ##
  54. # @brief Instantiate a validator
  55. #
  56. #@param name str: validator name
  57. #@param none_is_valid bool: if True None will be validated
  58. #@param **kwargs: more arguments for the validator
  59. def __init__(self, name, none_is_valid=False, display_name=None, help_text=None, **kwargs):
  60. if name is not None and name not in self._validators:
  61. raise LodelFatalError("No validator named '%s'" % name)
  62. self.__none_is_valid = none_is_valid
  63. self.__name = name
  64. self._opt_args = kwargs
  65. if display_name is None:
  66. display_name = name
  67. super().__init__(display_name, help_text)
  68. ##
  69. # @brief Calls the validator.
  70. #
  71. # @param value mixed:
  72. # @return mixed: The properly casted value
  73. # @throw ValidationError
  74. def __call__(self, value):
  75. if value is None:
  76. if self.__none_is_valid:
  77. return None
  78. else:
  79. raise ValidationError('None is not a valid value')
  80. if self.__none_is_valid and value is None:
  81. return None
  82. try:
  83. ret = self._validators[self.__name](value, **self._opt_args)
  84. return ret
  85. except Exception as exp:
  86. raise ValidationError(exp)
  87. ##
  88. # @brief Register a new validator
  89. #
  90. # @param name string: validator name
  91. # @param callback callable: the function that will validate a value
  92. # @param description string:
  93. @classmethod
  94. def register_validator(cls, name, callback, description=None):
  95. if name in cls._validators:
  96. raise NameError("A validator named '%s' allready exists" % name)
  97. ##
  98. # @todo Broken test for callable.
  99. if not inspect.isfunction(callback) and not inspect.ismethod(callback) and not hasattr(callback, '__call__'):
  100. raise TypeError("Callable expected but got %s" % type(callback))
  101. cls._validators[name] = callback
  102. cls._description[name] = description
  103. ##
  104. # @brief Get the validator list associated with description
  105. @classmethod
  106. def validators_list(cls):
  107. return copy.copy(cls._description)
  108. ##
  109. # @brief Creates and registers an iterative list validator
  110. #
  111. # @param elt_validator callable: The validator that will be used to validate
  112. # each of the list values.
  113. # @param validator_name string:
  114. # @param description None | string:
  115. # @param separator string: The element separator.
  116. # @return A Validator instance.
  117. @classmethod
  118. def create_list_validator(cls, validator_name, elt_validator, description=None, separator=','):
  119. def list_validator(value):
  120. res = list()
  121. if value is None:
  122. return res
  123. errors = list()
  124. for elt in value.split(separator):
  125. elt = elt_validator(elt)
  126. if len(elt) > 0:
  127. res.append(elt)
  128. return res
  129. description = "Convert value to an array" if description is None else description
  130. cls.register_validator(validator_name, list_validator, description)
  131. return cls(validator_name)
  132. ##
  133. # @brief Creates and registers a list validator that reads an array
  134. # and returns a string
  135. # @param elt_validator callable: The validator that will be used to validate
  136. # each elt value
  137. # @param validator_name string:
  138. # @param description None | string:
  139. # @param separator string: The element separator
  140. # @return A Validator instance
  141. @classmethod
  142. def create_write_list_validator(cls, validator_name, elt_validator, description=None, separator=','):
  143. def write_list_validator(value):
  144. res = ''
  145. for elt in value:
  146. res += elt_validator(elt) + ','
  147. return res[:len(res) - 1]
  148. description = "Convert value to a string" if description is None else description
  149. cls.register_validator(validator_name, write_list_validator, description)
  150. return cls(validator_name)
  151. ##
  152. # @brief Create and register a regular expression validator
  153. #
  154. # @param pattern str: regex pattern
  155. # @param validator_name str: The validator name
  156. # @param description str: Validator description
  157. # @return a Validator instance
  158. @classmethod
  159. def create_re_validator(cls, pattern, validator_name, description=None):
  160. def re_validator(value):
  161. if not re.match(pattern, value):
  162. raise ValidationError(
  163. "The value '%s' doesn't match the following pattern '%s'"
  164. % pattern)
  165. return value
  166. # registering the validator
  167. cls.register_validator(validator_name, re_validator,
  168. ("Match value to '%s'" % pattern)
  169. if description is None else description)
  170. return cls(validator_name)
  171. ##
  172. #  @return The list of registered validators.
  173. @classmethod
  174. def validators_list_str(cls):
  175. result = ''
  176. for name in sorted(cls._validators.keys()):
  177. result += "\t%016s" % name
  178. if name in cls._description and cls._description[name] is not None:
  179. result += ": %s" % cls._description[name]
  180. result += "\n"
  181. return result
  182. ##
  183. # @brief Integer value validator callback
  184. #
  185. # @remarks Is it intended that this function simply tries casting to an integer?
  186. # Is it also intended that it does not use any try/catch block?
  187. def int_val(value):
  188. return int(value)
  189. ##
  190. # @brief Output file validator callback
  191. #
  192. # @return A file object (if filename is '-' return sys.stderr)
  193. def file_err_output(value):
  194. if not isinstance(value, str):
  195. raise ValidationError("A string was expected but got '%s' " % value)
  196. if value == '-':
  197. return None
  198. return value
  199. ##
  200. # @brief Boolean validator callback
  201. def boolean_val(value):
  202. if isinstance(value, bool):
  203. return value
  204. if value.strip().lower() == 'true' or value.strip() == '1':
  205. value = True
  206. elif value.strip().lower() == 'false' or value.strip() == '0':
  207. value = False
  208. else:
  209. raise ValidationError("A boolean was expected but got '%s' " % value)
  210. return bool(value)
  211. ##
  212. # @brief Validate a directory path
  213. def directory_val(value):
  214. res = Validator('strip')(value)
  215. if not os.path.isdir(res):
  216. raise ValidationError("Following path don't exists or is not a directory : '%s'" % res)
  217. return res
  218. ##
  219. # @brief Validates a loglevel value
  220. def loglevel_val(value):
  221. valids = ['DEBUG', 'INFO', 'WARNING', 'SECURITY', 'ERROR', 'CRITICAL']
  222. if value.upper() not in valids:
  223. raise ValidationError(
  224. "The value '%s' is not a valid loglevel" % value)
  225. return value.upper()
  226. ##
  227. # @brief Validates a path
  228. #
  229. # @remarks is it intended to have both @ref directory_val and this function
  230. # right here?
  231. def path_val(value):
  232. if value is None or not os.path.exists(value):
  233. raise ValidationError(
  234. "path '%s' doesn't exists" % value)
  235. return value
  236. ##
  237. # @brief Validates None
  238. #
  239. # @remarks Purpose?
  240. def none_val(value):
  241. if value is None:
  242. return None
  243. raise ValidationError("This settings cannot be set in configuration file")
  244. ##
  245. # @brief Validates a string
  246. def str_val(value):
  247. try:
  248. return str(value)
  249. except Exception as exp:
  250. raise ValidationError("Can't to convert value to string: " + str(exp))
  251. ##
  252. # @brief Validate using a regex
  253. def regex_val(value, pattern):
  254. if re.match(pattern, value) is None:
  255. raise ValidationError("The value '%s' is not validated by : \
  256. r\"%s\"" % (value, pattern))
  257. return value
  258. ##
  259. # @brief Validate a hostname (ipv4 or ipv6)
  260. def host_val(value):
  261. if value == 'localhost':
  262. return value
  263. ok = False
  264. try:
  265. socket.inet_aton(value)
  266. return value
  267. except (TypeError, OSError):
  268. pass
  269. try:
  270. socket.inet_pton(socket.AF_INET6, value)
  271. return value
  272. except (TypeError, OSError):
  273. pass
  274. try:
  275. socket.getaddrinfo(value, 80)
  276. return value
  277. except (TypeError, socket.gaierror):
  278. msg = "The value '%s' is not a valid host"
  279. raise ValidationError(msg % value)
  280. def custom_list_validator(value, validator_name, validator_kwargs=None):
  281. validator_kwargs = dict() if validator_kwargs is None else validator_kwargs
  282. validator = Validator(validator_name, **validator_kwargs)
  283. for item in value.split():
  284. validator(item)
  285. return value.split()
  286. #
  287. # Default validators registration
  288. #
  289. Validator.register_validator('custom_list', custom_list_validator,
  290. 'A list validator that takes a "validator_name" as argument')
  291. Validator.register_validator('dummy', lambda value: value, 'Validate anything')
  292. Validator.register_validator('none', none_val, 'Validate None')
  293. Validator.register_validator('string', str_val, 'Validate string values')
  294. Validator.register_validator('strip', str.strip, 'String trim')
  295. Validator.register_validator('int', int_val, 'Integer value validator')
  296. Validator.register_validator('bool', boolean_val, 'Boolean value validator')
  297. Validator.register_validator('errfile', file_err_output,
  298. 'Error output file validator (return stderr if filename is "-")')
  299. Validator.register_validator('directory', directory_val,
  300. 'Directory path validator')
  301. Validator.register_validator('loglevel', loglevel_val, 'Loglevel validator')
  302. Validator.register_validator('path', path_val, 'path validator')
  303. Validator.register_validator('host', host_val, 'host validator')
  304. Validator.register_validator('regex', regex_val,
  305. 'RegEx name validator (take re as argument)')
  306. Validator.create_list_validator('list', Validator('strip'), description="Simple list validator. Validate a list of values separated by ','",
  307. separator=',')
  308. Validator.create_list_validator(
  309. 'directory_list',
  310. Validator('directory'),
  311. description="Validator for a list of directory path separated with ','",
  312. separator=',')
  313. Validator.create_write_list_validator(
  314. 'write_list',
  315. Validator('directory'),
  316. description="Validator for an array of values \
  317. which will be set in a string, separated by ','",
  318. separator=',')
  319. Validator.create_re_validator(
  320. r'^https?://[^\./]+.[^\./]+/?.*$',
  321. 'http_url',
  322. 'Url validator')
  323. ##
  324. # @brief Validator for Editorial Model components.
  325. #
  326. # Designed to validate a conf that indicates a class.field in an EM
  327. #
  328. # @todo modify the hardcoded dyncode import (it's a warning)
  329. def emfield_val(value):
  330. LodelContext.expose_modules(globals(),
  331. {'lodel.plugin.hooks': ['LodelHook']})
  332. spl = value.split('.')
  333. if len(spl) != 2:
  334. msg = "Expected a value in the form CLASSNAME.FIELDNAME but got : %s"
  335. raise ValidationError(msg % value)
  336. value = tuple(spl)
  337. # Late validation hook
  338. @LodelHook('lodel2_dyncode_bootstraped')
  339. def emfield_conf_check(hookname, caller, payload):
  340. import leapi_dyncode as dyncode # <-- dirty & quick
  341. classnames = {cls.__name__.lower(): cls for cls in dyncode.dynclasses}
  342. if value[0].lower() not in classnames:
  343. msg = "Following dynamic class do not exists in current EM : %s"
  344. raise ValidationError(msg % value[0])
  345. ccls = classnames[value[0].lower()]
  346. if value[1].lower() not in ccls.fieldnames(True):
  347. msg = "Following field not found in class %s : %s"
  348. raise ValidationError(msg % value)
  349. return value
  350. ##
  351. # @brief Validator for plugin name & its type optionally
  352. #
  353. # Able to check that the value is a plugin and if it is of a specific type
  354. def plugin_validator(value, ptype=None):
  355. if value:
  356. LodelContext.expose_modules(globals(), {
  357. 'lodel.plugin.hooks': ['LodelHook']})
  358. value = copy.copy(value)
  359. @LodelHook('lodel2_dyncode_bootstraped')
  360. def plugin_type_checker(hookname, caller, payload):
  361. LodelContext.expose_modules(globals(), {
  362. 'lodel.plugin.plugins': ['Plugin'],
  363. 'lodel.plugin.exceptions': ['PluginError']})
  364. if value is None:
  365. return
  366. try:
  367. plugin = Plugin.get(value)
  368. except PluginError:
  369. msg = "No plugin named %s found"
  370. msg %= value
  371. raise ValidationError(msg)
  372. if plugin._type_conf_name.lower() != ptype.lower():
  373. msg = "A plugin of type '%s' was expected but found a plugin \
  374. named '%s' that is a '%s' plugin"
  375. msg %= (ptype, value, plugin._type_conf_name)
  376. raise ValidationError(msg)
  377. return value
  378. return None
  379. Validator.register_validator(
  380. 'plugin',
  381. plugin_validator,
  382. 'plugin name & type validator')
  383. Validator.register_validator(
  384. 'emfield',
  385. emfield_val,
  386. 'EmField name validator')
  387. ##
  388. # Lodel 2 configuration specification
  389. #
  390. # @brief Append a piece of confspec
  391. #
  392. # @param orig dict : the confspec to update
  393. # @param section str : section name
  394. # @param key str
  395. # @param validator Validator : the validator to use to check this configuration key's value
  396. # @param default
  397. # @return new confspec
  398. #
  399. # @note orig is modified during the process
  400. def confspec_append(orig, section, key, validator, default):
  401. if section not in orig:
  402. orig[section] = dict()
  403. if key not in orig[section]:
  404. orig[section][key] = (default, validator)
  405. return orig
  406. ##
  407. # @brief Global specifications for lodel2 settings
  408. LODEL2_CONF_SPECS = {
  409. 'lodel2': {
  410. 'debug': (True, Validator('bool')),
  411. 'sitename': ('noname', Validator('strip')),
  412. 'runtest': (False, Validator('bool')),
  413. },
  414. 'lodel2.logging.*': {
  415. 'level': ('ERROR', Validator('loglevel')),
  416. 'context': (False, Validator('bool')),
  417. 'filename': ("-", Validator('errfile', none_is_valid=False)),
  418. 'backupcount': (5, Validator('int', none_is_valid=False)),
  419. 'maxbytes': (1024 * 10, Validator('int', none_is_valid=False)),
  420. },
  421. 'lodel2.editorialmodel': {
  422. 'emfile': ('em.pickle', Validator('strip')),
  423. 'emtranslator': ('picklefile', Validator('strip')),
  424. 'dyncode': ('leapi_dyncode.py', Validator('strip')),
  425. 'groups': ('', Validator('list')),
  426. 'editormode': (False, Validator('bool')),
  427. },
  428. 'lodel2.datasources.*': {
  429. 'read_only': (False, Validator('bool')),
  430. 'identifier': (None, Validator('string')),
  431. },
  432. 'lodel2.auth': {
  433. 'login_classfield': ('user.login', Validator('emfield')),
  434. 'pass_classfield': ('user.password', Validator('emfield')),
  435. },
  436. }