1
# This file is part of Checkbox.
3
# Copyright 2013, 2014 Canonical Ltd.
5
# Zygmunt Krynicki <zygmunt.krynicki@canonical.com>
7
# Checkbox is free software: you can redistribute it and/or modify
8
# it under the terms of the GNU General Public License version 3,
9
# as published by the Free Software Foundation.
12
# Checkbox is distributed in the hope that it will be useful,
13
# but WITHOUT ANY WARRANTY; without even the implied warranty of
14
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15
# GNU General Public License for more details.
17
# You should have received a copy of the GNU General Public License
18
# along with Checkbox. If not, see <http://www.gnu.org/licenses/>.
21
:mod:`plainbox.impl.secure.qualifiers` -- Job Qualifiers
22
========================================================
24
Qualifiers are callable objects that can be used to 'match' a job definition to
37
from plainbox.abc import IJobQualifier
38
from plainbox.i18n import gettext as _
39
from plainbox.impl import pod
40
from plainbox.impl.secure.origin import FileTextSource
41
from plainbox.impl.secure.origin import Origin
42
from plainbox.impl.secure.origin import UnknownTextSource
45
_logger = logging.getLogger("plainbox.secure.qualifiers")
48
class SimpleQualifier(IJobQualifier):
50
Abstract base class that implements common features of simple (non
51
composite) qualifiers. This allows two concrete subclasses below to
55
def __init__(self, origin, inclusive=True):
56
if origin is not None and not isinstance(origin, Origin):
57
raise TypeError(_('argument {!a}, expected {}, got {}').format(
58
'origin', Origin, type(origin)))
59
if not isinstance(inclusive, bool):
60
raise TypeError(_('argument {!a}, expected {}, got {}').format(
61
'inclusive', bool, type(inclusive)))
62
self._inclusive = inclusive
67
return self._inclusive
70
def is_primitive(self):
73
def designates(self, job):
74
return self.get_vote(job) == self.VOTE_INCLUDE
77
def get_simple_match(self, job):
79
Get a simple yes-or-no boolean answer if the given job matches the
80
simple aspect of this qualifier. This method should be overridden by
84
def get_vote(self, job):
86
Get one of the ``VOTE_IGNORE``, ``VOTE_INCLUDE``, ``VOTE_EXCLUDE``
87
votes that this qualifier associated with the specified job.
90
A IJobDefinition instance that is to be visited
92
* ``VOTE_INCLUDE`` if the job matches the simple qualifier concept
93
embedded into this qualifier and this qualifier is **inclusive**.
94
* ``VOTE_EXCLUDE`` if the job matches the simple qualifier concept
95
embedded into this qualifier and this qualifier is **not
97
* ``VOTE_IGNORE`` otherwise.
101
if self.get_simple_match(job):
103
return self.VOTE_INCLUDE
105
return self.VOTE_EXCLUDE
107
return self.VOTE_IGNORE
109
def get_primitive_qualifiers(self):
111
Return a list of primitives that constitute this qualifier.
114
A list of IJobQualifier objects that each is the smallest,
115
indivisible entity. Here it just returns a list of one element,
125
Origin of this qualifier
127
This property can be used to trace the origin of a qualifier back to
128
its definition point.
133
class RegExpJobQualifier(SimpleQualifier):
135
A JobQualifier that designates jobs by matching their id to a regular
139
def __init__(self, pattern, origin, inclusive=True):
141
Initialize a new RegExpJobQualifier with the specified pattern.
143
super().__init__(origin, inclusive)
145
self._pattern = re.compile(pattern)
146
except sre_constants.error as exc:
147
assert len(exc.args) == 1
148
# XXX: This is a bit crazy but this lets us have identical error
149
# messages across python3.2 all the way to 3.5. I really really
150
# wish there was a better way at fixing this.
151
exc.args = (re.sub(" at position \d+", "", exc.args[0]), )
153
self._pattern_text = pattern
155
def get_simple_match(self, job):
157
Check if the given job matches this qualifier.
159
This method should not be called directly, it is an implementation
160
detail of SimpleQualifier class.
162
return self._pattern.match(job.id) is not None
165
def pattern_text(self):
167
text of the regular expression embedded in this qualifier
169
return self._pattern_text
172
return "{0}({1!r}, inclusive={2})".format(
173
self.__class__.__name__, self._pattern_text, self._inclusive)
176
class JobIdQualifier(SimpleQualifier):
178
A JobQualifier that designates a single job with a particular id
181
def __init__(self, id, origin, inclusive=True):
182
super().__init__(origin, inclusive)
192
def get_simple_match(self, job):
194
Check if the given job matches this qualifier.
196
This method should not be called directly, it is an implementation
197
detail of SimpleQualifier class.
199
return self._id == job.id
202
return "{0}({1!r}, inclusive={2})".format(
203
self.__class__.__name__, self._id, self._inclusive)
206
class NonLocalJobQualifier(SimpleQualifier):
208
A JobQualifier that designates only non local jobs
211
def __init__(self, origin, inclusive=True):
212
super().__init__(origin, inclusive)
214
def get_simple_match(self, job):
216
Check if the given job matches this qualifier.
218
This method should not be called directly, it is an implementation
219
detail of SimpleQualifier class.
221
return job.plugin != 'local'
224
return "{0}(inclusive={1})".format(
225
self.__class__.__name__, self._inclusive)
228
class IMatcher(metaclass=abc.ABCMeta):
230
Interface for objects that perform some kind of comparison on a value
234
def match(self, value):
236
Match (or not) specified value
241
True if it matched, False otherwise
245
@functools.total_ordering
246
class OperatorMatcher(IMatcher):
248
A matcher that applies a binary operator to the value
251
def __init__(self, op, value):
260
The operator is typically one of the functions from the ``operator``
261
module. For example. operator.eq corresponds to the == python operator.
268
The right-hand-side value to apply to the operator
270
The left-hand-side is the value that is passed to :meth:`match()`
274
def match(self, value):
275
return self._op(self._value, value)
278
return "{0}({1!r}, {2!r})".format(
279
self.__class__.__name__, self._op, self._value)
281
def __eq__(self, other):
282
if isinstance(other, OperatorMatcher):
283
return self.op == other.op and self.value == other.value
285
return NotImplemented
287
def __lt__(self, other):
288
if isinstance(other, OperatorMatcher):
289
if self.op < other.op:
291
if self.value < other.value:
295
return NotImplemented
298
class PatternMatcher(IMatcher):
300
A matcher that compares values by regular expression pattern
303
def __init__(self, pattern):
304
self._pattern_text = pattern
305
self._pattern = re.compile(pattern)
308
def pattern_text(self):
309
return self._pattern_text
311
def match(self, value):
312
return self._pattern.match(value) is not None
315
return "{0}({1!r})".format(
316
self.__class__.__name__, self._pattern_text)
318
def __eq__(self, other):
319
if isinstance(other, PatternMatcher):
320
return self.pattern_text == other.pattern_text
322
return NotImplemented
324
def __lt__(self, other):
325
if isinstance(other, PatternMatcher):
326
return self.pattern_text < other.pattern_text
328
return NotImplemented
331
class FieldQualifier(SimpleQualifier):
333
A SimpleQualifer that uses matchers to compare particular fields
336
def __init__(self, field, matcher, origin, inclusive=True):
338
Initialize a new FieldQualifier with the specified field, matcher and
342
Name of the JobDefinition field to use
346
Inclusive selection flag (default: True)
348
super().__init__(origin, inclusive)
350
self._matcher = matcher
355
Name of the field to match
362
The IMatcher-implementing object to use to check for the match
366
def get_simple_match(self, job):
368
Check if the given job matches this qualifier.
370
This method should not be called directly, it is an implementation
371
detail of SimpleQualifier class.
373
field_value = getattr(job, str(self._field))
374
return self._matcher.match(field_value)
377
return "{0}({1!r}, {2!r}, inclusive={3})".format(
378
self.__class__.__name__, self._field, self._matcher,
382
class CompositeQualifier(pod.POD):
384
A JobQualifier that has qualifies jobs matching any inclusive qualifiers
385
while not matching all of the exclusive qualifiers
388
qualifier_list = pod.Field("qualifier_list", list, pod.MANDATORY)
391
def is_primitive(self):
394
def designates(self, job):
395
return self.get_vote(job) == IJobQualifier.VOTE_INCLUDE
397
def get_vote(self, job):
399
Get one of the ``VOTE_IGNORE``, ``VOTE_INCLUDE``, ``VOTE_EXCLUDE``
400
votes that this qualifier associated with the specified job.
403
A IJobDefinition instance that is to be visited
405
* ``VOTE_INCLUDE`` if the job matches at least one qualifier voted
406
to select it and no qualifiers voted to deselect it.
407
* ``VOTE_EXCLUDE`` if at least one qualifier voted to deselect it
408
* ``VOTE_IGNORE`` otherwise or if the list of qualifiers is empty.
412
if self.qualifier_list:
414
qualifier.get_vote(job)
415
for qualifier in self.qualifier_list])
417
return IJobQualifier.VOTE_IGNORE
419
def get_primitive_qualifiers(self):
420
return get_flat_primitive_qualifier_list(self.qualifier_list)
424
raise NonPrimitiveQualifierOrigin
427
IJobQualifier.register(CompositeQualifier)
430
class NonPrimitiveQualifierOrigin(Exception):
432
Exception raised when IJobQualifier.origin is meaningless as it is being
433
requested on a non-primitive qualifier such as the CompositeQualifier
437
# NOTE: using CompositeQualifier seems strange but it's a tested proven
438
# component so all we have to ensure is that we read the whitelist files
440
class WhiteList(CompositeQualifier):
442
A qualifier that understands checkbox whitelist files.
444
A whitelist file is a plain text, line oriented file. Each line represents
445
a regular expression pattern that can be matched against the id of a job.
447
The file can contain simple shell-style comments that begin with the pound
448
or hash key (#). Those are ignored. Comments can span both a fraction of a
449
line as well as the whole line.
451
For historical reasons each pattern has an implicit '^' and '$' prepended
452
and appended (respectively) to the actual pattern specified in the file.
455
def __init__(self, pattern_list, name=None, origin=None,
456
implicit_namespace=None):
458
Initialize a WhiteList object with the specified list of patterns.
460
The patterns must be already mangled with '^' and '$'.
463
self._origin = origin
464
self._implicit_namespace = implicit_namespace
465
if implicit_namespace is not None:
466
# If we have an implicit namespace then transform all the patterns
467
# without the namespace operator ('::')
468
namespace_pattern = implicit_namespace.replace('.', '\\.')
470
def transform_pattern(maybe_partial_id_pattern):
471
if "::" not in maybe_partial_id_pattern:
472
return "^{}::{}$".format(
473
namespace_pattern, maybe_partial_id_pattern[1:-1])
475
return maybe_partial_id_pattern
478
transform_pattern(pattern), origin, inclusive=True)
479
for pattern in pattern_list]
481
# Otherwise just use the patterns directly
483
RegExpJobQualifier(pattern, origin, inclusive=True)
484
for pattern in pattern_list]
485
super().__init__(qualifier_list)
488
return "<{} name:{!r}>".format(self.__class__.__name__, self.name)
493
name of this WhiteList (might be None)
498
def name(self, value):
500
set a new name for a WhiteList
507
origin object associated with this WhiteList (might be None)
512
def implicit_namespace(self):
514
namespace used to qualify patterns without explicit namespace
516
return self._implicit_namespace
519
def from_file(cls, pathname, implicit_namespace=None):
521
Load and initialize the WhiteList object from the specified file.
525
:param implicit_namespace:
526
(optional) implicit namespace for jobs that are using partial
527
identifiers (all jobs)
529
a fresh WhiteList object
531
pattern_list, max_lineno = cls._load_patterns(pathname)
532
name = os.path.splitext(os.path.basename(pathname))[0]
533
origin = Origin(FileTextSource(pathname), 1, max_lineno)
534
return cls(pattern_list, name, origin, implicit_namespace)
537
def from_string(cls, text, *, filename=None, name=None, origin=None,
538
implicit_namespace=None):
540
Load and initialize the WhiteList object from the specified string.
543
full text of the whitelist
545
(optional, keyword-only) filename from which text was read from.
546
This simulates a call to :meth:`from_file()` which properly
547
computes the name and origin of the whitelist.
549
(optional) name of the whitelist, only used if filename is not
552
(optional) origin of the whitelist, only used if a filename is not
553
specified. If omitted a default origin value will be constructed
554
out of UnknownTextSource instance
555
:param implicit_namespace:
556
(optional) implicit namespace for jobs that are using partial
557
identifiers (all jobs)
559
a fresh WhiteList object
561
The optional filename or a pair of name and origin arguments may be
562
provided in order to have additional meta-data. This is typically
563
needed when the :meth:`from_file()` method cannot be used as the caller
564
already has the full text of the intended file available.
566
_logger.debug("Loaded whitelist from %r", filename)
567
pattern_list, max_lineno = cls._parse_patterns(text)
568
# generate name and origin if filename is provided
569
if filename is not None:
570
name = WhiteList.name_from_filename(filename)
571
origin = Origin(FileTextSource(filename), 1, max_lineno)
573
# otherwise generate origin if it's not specified
575
origin = Origin(UnknownTextSource(), 1, max_lineno)
576
return cls(pattern_list, name, origin, implicit_namespace)
579
def name_from_filename(cls, filename):
581
Compute the name of a whitelist based on the name
582
of the file it is stored in.
584
return os.path.splitext(os.path.basename(filename))[0]
587
def _parse_patterns(cls, text):
589
Load whitelist patterns from the specified text
592
string of text, including newlines, to parse
594
(pattern_list, lineno) where lineno is the final line number
595
(1-based) and pattern_list is a list of regular expression strings
596
parsed from the whitelist.
598
from plainbox.impl.xparsers import Re
599
from plainbox.impl.xparsers import Visitor
600
from plainbox.impl.xparsers import WhiteList
602
class WhiteListVisitor(Visitor):
605
self.pattern_list = []
608
def visit_Re_node(self, node: Re):
609
self.pattern_list.append(r"^{}$".format(node.text.strip()))
610
self.lineno = max(node.lineno, self.lineno)
611
return super().generic_visit(node)
613
visit_ReFixed_node = visit_Re_node
614
visit_RePattern_node = visit_Re_node
615
visit_ReErr_node = visit_Re_node
617
visitor = WhiteListVisitor()
618
visitor.visit(WhiteList.parse(text))
619
return visitor.pattern_list, visitor.lineno
622
def _load_patterns(cls, pathname):
624
Load whitelist patterns from the specified file
627
pathname of the file to load and parse
629
(pattern_list, lineno) where lineno is the final line number
630
(1-based) and pattern_list is a list of regular expression strings
631
parsed from the whitelist.
633
with open(pathname, "rt", encoding="UTF-8") as stream:
634
return cls._parse_patterns(stream.read())
637
def get_flat_primitive_qualifier_list(qualifier_list):
638
return list(itertools.chain(*[
639
qual.get_primitive_qualifiers()
640
for qual in qualifier_list]))
643
def select_jobs(job_list, qualifier_list):
648
A list of JobDefinition objects
649
:param qualifier_list:
650
A list of IJobQualifier objects.
652
A sub-list of JobDefinition objects, selected from job_list.
654
# Flatten the qualifier list, so that we can see the fine structure of
655
# composite objects, such as whitelists.
656
flat_qualifier_list = get_flat_primitive_qualifier_list(qualifier_list)
657
# Short-circuit if there are no jobs to select. Min is used later and this
658
# will allow us to assume that the matrix is not empty.
659
if not flat_qualifier_list:
661
# Vote matrix, encodes the vote cast by a particular qualifier for a
662
# particular job. Visually it's a two-dimensional array like this:
674
# ------------------->
677
# The vertical axis represents qualifiers from the flattened qualifier
678
# list. The horizontal axis represents jobs from job list. Dots represent
679
# inclusion, X represents exclusion.
681
# The result of the select_job() function is a list of jobs that have at
682
# least one inclusion and no exclusions. The resulting list is ordered by
683
# increasing qualifier index.
685
# The algorithm implemented below is composed of two steps.
687
# The first step iterates over the vote matrix (row-major, meaning that we
688
# visit all columns for each visit of one row) and constructs two
689
# structures: a set of jobs that got VOTE_INCLUDE and a list of those jobs,
690
# in the order of discovery. All VOTE_EXCLUDE votes are collected in
693
# The second step filters-out all items from the excluded job set from the
694
# selected job list. For extra efficiency the algorithm operates on
695
# integers representing the index of a particular job in job_list.
697
# The final complexity is O(N x M) + O(M), where N is the number of
698
# qualifiers (flattened) and M is the number of jobs. The algorithm assumes
699
# that set lookup is a O(1) operation which is true enough for python.
701
# A possible optimization would differentiate qualifiers that may select
702
# more than one job and fall-back to the current implementation while
703
# short-circuiting qualifiers that may select at most one job with a
704
# separate set lookup. That would make the algorithm "mostly" linear in the
707
# As a separate feature, we might return a list of qualifiers that never
708
# matched anything. That may be helpful for debugging.
710
id_to_index_map = {job.id: index for index, job in enumerate(job_list)}
713
for qualifier in flat_qualifier_list:
714
if (isinstance(qualifier, FieldQualifier)
715
and qualifier.field == 'id'
716
and isinstance(qualifier.matcher, OperatorMatcher)
717
and qualifier.matcher.op == operator.eq):
718
# optimize the super-common case where a qualifier refers to
719
# a specific job by using the id_to_index_map to instantly
720
# perform the requested operation on a single job
722
j_index = id_to_index_map[qualifier.matcher.value]
724
# The lookup can fail if the pattern is a constant reference to
725
# a generated job that doens't exist yet. To maintain correctness
726
# we should just ignore it, as it would not match anything yet.
728
job = job_list[j_index]
729
vote = qualifier.get_vote(job)
730
if vote == IJobQualifier.VOTE_INCLUDE:
731
if j_index in included_set:
733
included_set.add(j_index)
734
included_list.append(j_index)
735
elif vote == IJobQualifier.VOTE_EXCLUDE:
736
excluded_set.add(j_index)
737
elif vote == IJobQualifier.VOTE_IGNORE:
740
for j_index, job in enumerate(job_list):
741
vote = qualifier.get_vote(job)
742
if vote == IJobQualifier.VOTE_INCLUDE:
743
if j_index in included_set:
745
included_set.add(j_index)
746
included_list.append(j_index)
747
elif vote == IJobQualifier.VOTE_EXCLUDE:
748
excluded_set.add(j_index)
749
elif vote == IJobQualifier.VOTE_IGNORE:
751
return [job_list[index] for index in included_list
752
if index not in excluded_set]