Merge ~jslarraz/review-tools:add-component-support into review-tools:master

Proposed by Jorge Sancho Larraz
Status: Rejected
Rejected by: Jorge Sancho Larraz
Proposed branch: ~jslarraz/review-tools:add-component-support
Merge into: review-tools:master
Diff against target: 1223 lines (+1145/-0)
11 files modified
.launchpad.yaml (+1/-0)
bin/component-review (+120/-0)
check-names.list (+4/-0)
reviewtools/cr_component.py (+487/-0)
reviewtools/schemas/component.json (+63/-0)
reviewtools/schemas/hook.json (+50/-0)
reviewtools/sr_common.py (+1/-0)
reviewtools/tests/test_cr_component.py (+159/-0)
reviewtools/tests/test_schema_base.py (+44/-0)
reviewtools/tests/test_schema_component.py (+145/-0)
reviewtools/tests/test_schema_hook.py (+71/-0)
Reviewer Review Type Date Requested Status
Alex Murray Needs Fixing
Review via email: mp+462423@code.launchpad.net

Commit message

components: add basic support for components

Description of the change

Add initial support for components.

Example commands:

# Run tests to validate the component.json schema
python3 -m pytest reviewtools/tests/test_schema_component.py

# Run tests to validate ReviewComponent checks
python3 -m pytest reviewtools/tests/test_sr_component.py

# Run a basic review and print the report
bin/component-review tests/test.comp tests/test.snap

To post a comment you must log in.
Revision history for this message
Alex Murray (alexmurray) wrote :

Thanks @jslarraz - some initial comments - will do a more thorough review next week but so far this looks really good - just some minor things.

lpci is failing:

  :: ModuleNotFoundError: No module named 'jsonschema'

  - I think you can probably just add python3-jsonschema to the list of packages in the .launchpad.yaml to fix this

also see inline comments.

review: Needs Fixing
Revision history for this message
Alex Murray (alexmurray) wrote :

I took a more thorough look at this MR this morning - no additional comments at this time other than those previous - thanks @jslarraz.

9973adb... by Jorge Sancho Larraz

Add jsonschema to .launchpad.yaml and fix typos

Revision history for this message
Jorge Sancho Larraz (jslarraz) wrote :

New version released addressing your commentaries.

Regarding the idea of defining hooks inline in the main schema rather than using a separate file, my initial intention was to reuse the hook definition in the snap.yaml schema, so we only need to update it on one place in the future (if/when new attributes are defined for hooks). Also the validation is seamless as jsonschema follows the reference automatically.
That said, I would also be happy to include it in the main component schema if you think it would be better

5ca14a4... by Jorge Sancho Larraz

Remove test.snap and test.comp from tests/ as they trigger unintended functional tests. Updating test_cr_component.py accordingly

9172d08... by Jorge Sancho Larraz

Fix style for pylint and flake8

cc8ad0e... by Jorge Sancho Larraz

Fix check names

Revision history for this message
Callahan Kovacs (mr-cal) :
99a807c... by Jorge Sancho Larraz

Update hooks to support command_chain, environment and passthrough parameters

5d76be6... by Jorge Sancho Larraz

Fix format

a34173e... by Jorge Sancho Larraz

- Add `components` to snap optional parameters
- Change component layout check from `warn` to `info` as it is pending implementation
- Add `check_squashfs_resquash` test as a copy pasta from sr_security until `containers` are available

ae63f1b... by Jorge Sancho Larraz

- component-review output more similar to snap-review

09fd0b4... by Jorge Sancho Larraz

- simple check for file type

Unmerged commits

09fd0b4... by Jorge Sancho Larraz

- simple check for file type

Succeeded
[SUCCEEDED] test:0 (build)
[SUCCEEDED] coverage:0 (build)
12 of 2 results
ae63f1b... by Jorge Sancho Larraz

- component-review output more similar to snap-review

Succeeded
[SUCCEEDED] test:0 (build)
[SUCCEEDED] coverage:0 (build)
12 of 2 results
a34173e... by Jorge Sancho Larraz

- Add `components` to snap optional parameters
- Change component layout check from `warn` to `info` as it is pending implementation
- Add `check_squashfs_resquash` test as a copy pasta from sr_security until `containers` are available

Succeeded
[SUCCEEDED] test:0 (build)
[SUCCEEDED] coverage:0 (build)
12 of 2 results
5d76be6... by Jorge Sancho Larraz

Fix format

Succeeded
[SUCCEEDED] test:0 (build)
[SUCCEEDED] coverage:0 (build)
12 of 2 results
99a807c... by Jorge Sancho Larraz

Update hooks to support command_chain, environment and passthrough parameters

Failed
[FAILED] test:0 (build)
[WAITING] coverage:0 (build)
12 of 2 results
cc8ad0e... by Jorge Sancho Larraz

Fix check names

Succeeded
[SUCCEEDED] test:0 (build)
[SUCCEEDED] coverage:0 (build)
12 of 2 results
9172d08... by Jorge Sancho Larraz

Fix style for pylint and flake8

Failed
[FAILED] test:0 (build)
[WAITING] coverage:0 (build)
12 of 2 results
5ca14a4... by Jorge Sancho Larraz

Remove test.snap and test.comp from tests/ as they trigger unintended functional tests. Updating test_cr_component.py accordingly

Failed
[FAILED] test:0 (build)
[WAITING] coverage:0 (build)
12 of 2 results
9973adb... by Jorge Sancho Larraz

Add jsonschema to .launchpad.yaml and fix typos

Failed
[FAILED] test:0 (build)
[WAITING] coverage:0 (build)
12 of 2 results
d5aef76... by Jorge Sancho Larraz

components: add basic support for components

Failed
[FAILED] test:0 (build)
[WAITING] coverage:0 (build)
12 of 2 results

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/.launchpad.yaml b/.launchpad.yaml
2index e2699a2..18fa969 100644
3--- a/.launchpad.yaml
4+++ b/.launchpad.yaml
5@@ -16,6 +16,7 @@ jobs:
6 - jq
7 - pylint
8 - python3-coverage
9+ - python3-jsonschema
10 - python3-magic
11 - python3-requests
12 - python3-ruamel.yaml
13diff --git a/bin/component-review b/bin/component-review
14new file mode 100755
15index 0000000..48a1504
16--- /dev/null
17+++ b/bin/component-review
18@@ -0,0 +1,120 @@
19+#!/usr/bin/python3
20+# Author: Jorge Sancho Larraz <jorge.sancho.larraz@canonical.com>
21+# Copyright (C) 2024 Canonical Ltd.
22+#
23+# This program is free software: you can redistribute it and/or modify
24+# it under the terms of the GNU General Public License as published by
25+# the Free Software Foundation; version 3 of the License.
26+#
27+# This program is distributed in the hope that it will be useful,
28+# but WITHOUT ANY WARRANTY; without even the implied warranty of
29+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
30+# GNU General Public License for more details.
31+#
32+# You should have received a copy of the GNU General Public License
33+# along with this program. If not, see <http://www.gnu.org/licenses/>.
34+
35+import argparse
36+
37+import sys
38+import textwrap
39+from reviewtools.cr_component import ComponentReview
40+import json
41+
42+
43+def print_findings(results, description):
44+ """
45+ Print a summary of the issues found.
46+ """
47+
48+ if not description or not results:
49+ return ""
50+ print(description)
51+ print("".center(len(description), "-"))
52+ for key in sorted(results.keys()):
53+ print(" - %s" % key)
54+ print("\t%s" % results[key]["text"])
55+ if "link" in results[key]:
56+ print("\t%s" % results[key]["link"])
57+
58+def main():
59+ parser = argparse.ArgumentParser(
60+ prog="component-review",
61+ formatter_class=argparse.RawDescriptionHelpFormatter,
62+ description="Check a component package for errors",
63+ epilog=textwrap.dedent(
64+ """\
65+ RETURN CODES
66+ 0 found no errors or warnings
67+ 1 checks not run due to fatal error
68+ 2 found only errors or errors and warnings
69+ 3 found only warnings
70+ """
71+ ),
72+ )
73+ parser.add_argument("comp_fn", type=str, help="component file to be inspected")
74+ parser.add_argument("snap_fn", type=str, help="snap file the component belongs to")
75+ parser.add_argument(
76+ "-v", "--verbose", help="increase output verbosity", action="store_true"
77+ )
78+ parser.add_argument("--json", help="print json output", action="store_true")
79+ parser.add_argument(
80+ "--sdk",
81+ help="use output format suitable for the Ubuntu SDK",
82+ action="store_true",
83+ )
84+ parser.add_argument(
85+ "overrides",
86+ type=str,
87+ nargs="?",
88+ help="overrides to apply (eg, framework, security " "policies, etc)",
89+ default=None,
90+ )
91+
92+ args = parser.parse_args()
93+
94+ rc = 0
95+ componentReview = ComponentReview(args.comp_fn, args.snap_fn)
96+ try:
97+ componentReview.do_checks()
98+ # componentReview.do_report()
99+ except Exception:
100+ rc = 1
101+
102+ if args.json:
103+ print(
104+ json.dumps(
105+ componentReview.review_report, sort_keys=True, indent=2, separators=(",", ": ")
106+ )
107+ )
108+ elif args.sdk:
109+ print("= %s =" % "snap.v2_component")
110+ print(
111+ json.dumps(componentReview.review_report, sort_keys=True, indent=2, separators=(",", ": "))
112+ )
113+ else:
114+ print_findings(componentReview.review_report["error"], "Errors")
115+ print_findings(componentReview.review_report["warn"], "Warnings")
116+ if args.verbose:
117+ print_findings(componentReview.review_report["info"], "Info")
118+ if rc == 1:
119+ print("%s: RUNTIME ERROR" % args.comp_fn)
120+ elif componentReview.review_report["warn"] or componentReview.review_report["error"]:
121+ print("%s: FAIL" % args.comp_fn)
122+ else:
123+ print("%s: pass" % args.comp_fn)
124+
125+ if componentReview.review_report["error"]:
126+ rc = 2
127+ elif componentReview.review_report["warn"]:
128+ rc = 3
129+
130+ sys.exit(rc)
131+
132+
133+if __name__ == "__main__":
134+ try:
135+ main()
136+ except KeyboardInterrupt:
137+ print("Aborted.")
138+ sys.exit(1)
139diff --git a/check-names.list b/check-names.list
140index deb608f..251ccd1 100644
141--- a/check-names.list
142+++ b/check-names.list
143@@ -1,3 +1,7 @@
144+component-v2:component_hooks|
145+component-v2:component_to_snap_relation|
146+component-v2:snap_to_component_relation|
147+component-v2:validate_component_schema|
148 declaration-snap-v2:app_slot_known|
149 declaration-snap-v2:interface-reference|
150 declaration-snap-v2:plugs_connection|
151diff --git a/reviewtools/cr_component.py b/reviewtools/cr_component.py
152new file mode 100644
153index 0000000..f76121e
154--- /dev/null
155+++ b/reviewtools/cr_component.py
156@@ -0,0 +1,487 @@
157+# Author: Jorge Sancho Larraz <jorge.sancho.larraz@canonical.com>
158+# Copyright (C) 2024 Canonical Ltd.
159+#
160+# This program is free software: you can redistribute it and/or modify
161+# it under the terms of the GNU General Public License as published by
162+# the Free Software Foundation; version 3 of the License.
163+#
164+# This program is distributed in the hope that it will be useful,
165+# but WITHOUT ANY WARRANTY; without even the implied warranty of
166+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
167+# GNU General Public License for more details.
168+#
169+# You should have received a copy of the GNU General Public License
170+# along with this program. If not, see <http://www.gnu.org/licenses/>.
171+
172+import os
173+import json
174+import jsonschema
175+import ruamel.yaml
176+
177+from reviewtools.common import Review, ReviewException, error, open_file_read, cleanup_unpack
178+from reviewtools.sr_common import SnapReview
179+from reviewtools.sr_lint import SnapReviewLint
180+
181+# Repack test
182+import re
183+import sys
184+import shutil
185+import copy
186+from reviewtools.common import (
187+ set_lang,
188+ restore_lang,
189+ cmd,
190+ cmdIgnoreErrorStrings,
191+ create_tempdir,
192+ MKSQUASHFS_DEFAULT_COMPRESSION,
193+ MKSQUASHFS_OPTS,
194+ UNSQUASHFS_IGNORED_ERRORS,
195+ unsquashfs_supports_ignore_errors,
196+ StatLLN,
197+ SNAP_MINIMUM_SIZE,
198+)
199+from reviewtools.overrides import (
200+ sec_mode_overrides,
201+ sec_resquashfs_overrides,
202+)
203+
204+
205+#
206+# Utility classes
207+#
208+class ComponentReviewException(ReviewException):
209+ """This class represents ComponentReview exceptions"""
210+
211+
212+class ComponentReview(SnapReview):
213+ """This class represents component reviews"""
214+
215+ def __init__(self, comp_fn, snap_fn, overrides=None):
216+
217+ # Run checks for snap.yaml
218+ self.snapReviewLint = SnapReviewLint(snap_fn)
219+ self.snapReviewLint.do_checks()
220+ self.snap_yaml = self.snapReviewLint.snap_yaml
221+ cleanup_unpack()
222+
223+ # Check 'fn' file exists and extract it
224+ Review.__init__(self, comp_fn, "component-v2", overrides=overrides)
225+
226+ # initialize ruamel.yaml loader
227+ yaml = ruamel.yaml.YAML(typ="safe")
228+ yaml.allow_duplicate_keys = False
229+
230+ # Load meta/component.yaml
231+ component_yaml = self._extract_component_yaml()
232+ try:
233+ self.component_yaml = yaml.load(component_yaml)
234+ except ruamel.yaml.constructor.DuplicateKeyError as e:
235+ raise ComponentReviewException(e.problem)
236+ except Exception: # pragma: nocover
237+ error("Could not load component.yaml. Does it exists?")
238+ component_yaml.close()
239+
240+ # Load schema
241+ with open("reviewtools/schemas/component.json") as fd:
242+ self.component_schema = json.loads(fd.read())
243+
244+ # def check_component_file_name(self, fn):
245+ # # Check component file name is valid. Update it if regex gets approved
246+ # # ^(?=.{2,40}\+.{2,40}\_.*$)^((?:[a-z0-9]+-?)*[a-z](?:-?[a-z0-9])*)\+((?:[a-z0-9]+-?)*[a-z](?:-?[a-z0-9])*)\_\d\.comp)$
247+ # aux = fn.split('.')
248+ # if len(aux) != 2:
249+ # error("Wrong component file name. It must be '<snap name>+<component name>_<component revision>.comp'.")
250+ # if aux[1] != "comp":
251+ # error("Wrong component file name. Component file extension must be '.comp'.")
252+ # aux = aux[0].split('_')
253+ # if len(aux) != 2:
254+ # error("Wrong component file name. It must be '<snap name>+<component name>_<component revision>.comp'.")
255+ # if not aux[1].isnumeric():
256+ # error("Wrong component file name. Revision must be numeric")
257+ # aux = aux[0].split('+')
258+ # if len(aux) != 2:
259+ # error("Wrong component file name. It must be '<snap name>+<component name>_<component revision>.comp'.")
260+ # if not self._verify_pkgname(aux[1]):
261+ # error("Wrong component file name. Component name must be in format ^(?:[a-z0-9]+-?)*[a-z](?:-?[a-z0-9])*$.")
262+ # if not self._verify_pkgname(aux[0]):
263+ # error("Wrong component file name. Snap name must be in format ^(?:[a-z0-9]+-?)*[a-z](?:-?[a-z0-9])*$.")
264+
265+ # Since coverage is looked at via the testsuite and the testsuite mocks
266+ # this out, don't cover this
267+ def _extract_component_yaml(self): # pragma: nocover
268+ """Extract and read the component.yaml"""
269+ y = os.path.join(self.unpack_dir, "meta/component.yaml")
270+ if not os.path.isfile(y):
271+ error("Could not find component.yaml.")
272+ return open_file_read(y)
273+
274+ def check_component_schema(self):
275+ # Load component schema and validate
276+ t = "info"
277+ n = self._get_check_name("validate_component_schema")
278+ s = "OK"
279+ try:
280+ jsonschema.validate(self.component_yaml, self.component_schema)
281+ except Exception as e:
282+ t = "error"
283+ s = e.message
284+ self._add_result(t, n, s)
285+
286+ def check_component_to_snap_relation(self):
287+ t = "info"
288+ n = self._get_check_name("component_to_snap_relation")
289+ s = "OK"
290+ if not self.component_yaml["component"].startswith(self.snap_yaml["name"] + '+'):
291+ t = "error"
292+ s = ("'component' field in 'component.yaml' does not start with <snap name>. However, "
293+ "its format must be '<snap name>+<component name>'.")
294+ self._add_result(t, n, s)
295+
296+ def check_snap_to_component_relation(self):
297+ t = "info"
298+ n = self._get_check_name("snap_to_component_relation")
299+ s = "OK"
300+ if "components" not in self.snap_yaml or \
301+ not self.component_yaml["component"].split("+")[1] in self.snap_yaml["components"]:
302+ t = "error"
303+ s = "Missing component '%s' in snap.yaml:components" % self.component_yaml["component"].split("+")[1]
304+ self._add_result(t, n, s)
305+
306+ def check_hooks(self):
307+ # Verify only allowed hooks exist
308+ if os.path.isdir(self.unpack_dir + "/meta/hooks"):
309+ for hook in os.listdir(self.unpack_dir + "/meta/hooks"):
310+ if hook not in self.component_schema["properties"]["hooks"]["propertyNames"]["enum"] + [".", ".."]:
311+ t = "error"
312+ n = self._get_check_name("component_hooks")
313+ s = "Unexpected hook '%s' found in /meta/hooks directory" % hook
314+ self._add_result(t, n, s)
315+
316+ def check_component_layout(self):
317+ t = "info"
318+ n = self._get_check_name("component_layout")
319+ s = "this check is not implemented yet"
320+ self._add_result(t, n, s)
321+
322+ def check_file_types(self):
323+ # todo: simple check for file type, improve it once containers are merged
324+ errors = []
325+ unsquashfs_lln_hdr, unsquashfs_lln_entries = self._unsquashfs_lln(self.pkg_filename)
326+ for (line, item) in unsquashfs_lln_entries:
327+ if item is None:
328+ continue
329+
330+ fname = item[StatLLN.FILENAME]
331+ ftype = item[StatLLN.FILETYPE]
332+ if ftype not in ["d", "-", "l"]:
333+ errors.append("file type '%s' not allowed (%s)" % (ftype, fname))
334+ continue
335+
336+ t = "info"
337+ n = self._get_check_name("squashfs_files")
338+ s = "OK"
339+ if len(errors) > 0:
340+ t = "error"
341+ s = "found errors in file output: %s" % ", ".join(errors)
342+ self._add_result(t, n, s)
343+
344+ def _unsquashfs_stat(self, snap_pkg):
345+ """Run unsquashfs -stat on a snap package"""
346+ (origLANG, origLC_ALL) = set_lang("C.UTF-8", "C.UTF-8")
347+ (rc, out) = cmd(["unsquashfs", "-stat", snap_pkg])
348+ restore_lang(origLANG, origLC_ALL)
349+ return rc, out
350+
351+ def check_squashfs_resquash(self):
352+ """Check resquash of squashfs"""
353+ fn = os.path.abspath(self.pkg_filename)
354+
355+ # Verify squashfs has no fragments. If it does, it will not resquash
356+ # properly (LP: #1576763). This stat output for fragments has been
357+ # stable for at least the last 7 years, so just parse it. If it changes
358+ # we can consider examing the superblock directly.
359+ (rc, out) = self._unsquashfs_stat(fn)
360+ if rc != 0:
361+ t = "error"
362+ n = self._get_check_name("squashfs_stat")
363+ s = "could not stat squashfs"
364+ self._add_result(t, n, s)
365+ return
366+
367+ comp = None
368+ comp_pat = re.compile(r"^Compression [a-z0-9]+$")
369+ for line in out.splitlines():
370+ if comp_pat.search(line):
371+ comp = line.split()[1]
372+ if comp is None:
373+ t = "error"
374+ n = self._get_check_name("squashfs_compression")
375+ s = "could not determine compression algorithm"
376+ self._add_result(t, n, s)
377+ return
378+ elif comp not in self.supported_compression_algorithms:
379+ t = "error"
380+ n = self._get_check_name("squashfs_compression")
381+ s = "unsupported compression algorithm '%s'" % comp
382+ self._add_result(t, n, s)
383+ return
384+
385+ if "\nNumber of fragments 0\n" not in out and (
386+ "SNAP_ENFORCE_RESQUASHFS" not in os.environ
387+ or (
388+ "SNAP_ENFORCE_RESQUASHFS" in os.environ
389+ and os.environ["SNAP_ENFORCE_RESQUASHFS"] != "0"
390+ )
391+ ):
392+ link = "https://forum.snapcraft.io/t/automated-reviews-and-snapcraft-2-38/4982/17"
393+ t = "error"
394+ n = self._get_check_name("squashfs_fragments")
395+ s = (
396+ "The squashfs was built without '-no-fragments'. Please "
397+ + "ensure the snap is created with either 'snapcraft pack "
398+ + "<DIR>' (using snapcraft >= 2.38) or 'mksquashfs <dir> "
399+ + "<snap> %s'" % " ".join(MKSQUASHFS_OPTS)
400+ + ". If using electron-builder, "
401+ "please upgrade to latest stable (>= 20.14.7). See %s "
402+ "for details." % link
403+ )
404+
405+ if self.snap_yaml["name"] in sec_resquashfs_overrides:
406+ t = "info"
407+ s = "OK (check not enforced for this snap): %s" % s
408+
409+ self._add_result(t, n, s)
410+ return
411+
412+ # Verify squashfs supports the -fstime option, if not, warn (which
413+ # blocks in store)
414+ (rc, out) = cmd(["unsquashfs", "-fstime", fn])
415+ if rc != 0:
416+ t = "warn"
417+ n = self._get_check_name("squashfs_supports_fstime")
418+ s = "could not determine fstime of squashfs"
419+ self._add_result(t, n, s)
420+ return
421+ fstime = out.strip()
422+
423+ if (
424+ "SNAP_ENFORCE_RESQUASHFS" in os.environ
425+ and os.environ["SNAP_ENFORCE_RESQUASHFS"] == "0"
426+ ):
427+ t = "info"
428+ n = self._get_check_name("squashfs_repack_checksum")
429+ s = "OK (check not enforced)"
430+ self._add_result(t, n, s)
431+ return
432+
433+ tmpdir = create_tempdir() # this is autocleaned
434+ tmp_unpack = os.path.join(tmpdir, "squashfs-root")
435+ tmp_repack = os.path.join(tmpdir, "repack.snap")
436+ fakeroot_env = os.path.join(tmpdir, "fakeroot.env")
437+
438+ # Don't use -all-root since the snap might have other users in it
439+ # NOTE: adding -no-xattrs here causes resquashfs to fail (unsquashfs
440+ # and mksquash use -xattrs by default. By specifying -no-xattrs to
441+ # unsquashfs/mksquashfs, we enforce not supportinging them since the
442+ # checksums will always be different because the original squash would
443+ # have them but the repack would not). If we ever decide to support
444+ # xattrs in snaps, would have to see why thre requash fails with
445+ # -xattrs.
446+ mksquashfs_ignore_opts = ["-all-root"]
447+
448+ curdir = os.getcwd()
449+ os.chdir(tmpdir)
450+ # ensure we don't alter the permissions from the unsquashfs
451+ old_umask = os.umask(000)
452+
453+ fakeroot_cmd = []
454+ if "SNAP_FAKEROOT_RESQUASHFS" in os.environ:
455+ # We could use -l $SNAP/usr/lib/... --faked $SNAP/usr/bin/faked if
456+ # os.environ['SNAP'] is set, but instead we let the snap packaging
457+ # make fakeroot work correctly and keep this simple.
458+ fakeroot_cmd = ["fakeroot", "--unknown-is-real"]
459+
460+ if shutil.which(fakeroot_cmd[0]) is None: # pragma: nocover
461+ t = "error"
462+ n = self._get_check_name("has_fakeroot")
463+ s = "Could not find 'fakeroot' command"
464+ self._add_result(t, n, s)
465+ return
466+
467+ try:
468+ fakeroot_args = []
469+ mksquash_opts = copy.copy(MKSQUASHFS_OPTS)
470+ if comp != MKSQUASHFS_DEFAULT_COMPRESSION:
471+ idx = mksquash_opts.index(MKSQUASHFS_DEFAULT_COMPRESSION)
472+ mksquash_opts[idx] = comp
473+
474+ if "SNAP_FAKEROOT_RESQUASHFS" in os.environ:
475+ # run unsquashfs under fakeroot, saving the session to be
476+ # reused by mksquashfs and thus preserving
477+ # uids/gids/devices/etc
478+ fakeroot_args = ["-s", fakeroot_env]
479+
480+ mksquash_opts = []
481+ for i in MKSQUASHFS_OPTS:
482+ if i not in mksquashfs_ignore_opts:
483+ mksquash_opts.append(i)
484+
485+ cmdline = (
486+ fakeroot_cmd
487+ + fakeroot_args
488+ + ["unsquashfs", "-no-progress", "-d", tmp_unpack]
489+ )
490+ if unsquashfs_supports_ignore_errors():
491+ cmdline.append("-ignore-errors")
492+ cmdline.append("-quiet")
493+ cmdline.append(fn)
494+
495+ (rc, out) = cmdIgnoreErrorStrings(cmdline, UNSQUASHFS_IGNORED_ERRORS)
496+ if rc != 0:
497+ raise ReviewException(
498+ "could not unsquash '%s': %s" % (os.path.basename(fn), out)
499+ )
500+
501+ fakeroot_args = []
502+ if "SNAP_FAKEROOT_RESQUASHFS" in os.environ:
503+ fakeroot_args = ["-i", fakeroot_env]
504+
505+ cmdline = (
506+ fakeroot_cmd
507+ + fakeroot_args
508+ + ["mksquashfs", tmp_unpack, tmp_repack, "-fstime", fstime]
509+ + mksquash_opts
510+ )
511+
512+ (rc, out) = cmd(cmdline)
513+ if rc != 0:
514+ raise ReviewException(
515+ "could not mksquashfs '%s': %s"
516+ % (os.path.relpath(tmp_unpack, tmpdir), out)
517+ )
518+ except ReviewException as e:
519+ t = "error"
520+ n = self._get_check_name("squashfs_resquash")
521+ self._add_result(t, n, str(e))
522+ return
523+ finally:
524+ os.umask(old_umask)
525+ os.chdir(curdir)
526+
527+ # Now calculate the hashes
528+ t = "info"
529+ n = self._get_check_name("squashfs_repack_checksum")
530+ s = "OK"
531+ link = None
532+
533+ (rc, out) = cmd(["sha512sum", fn])
534+ if rc != 0:
535+ t = "error"
536+ s = "could not determine checksum of '%s'" % os.path.basename(fn)
537+ self._add_result(t, n, s)
538+ return
539+ orig_sum = out.split()[0]
540+
541+ # If repacked package is smaller then original one, that means it's
542+ # truncated with the minimum size.
543+ if os.stat(fn).st_size == SNAP_MINIMUM_SIZE and (
544+ os.stat(tmp_repack).st_size < os.stat(fn).st_size):
545+ with open(tmp_repack, 'ab') as f:
546+ f.truncate(SNAP_MINIMUM_SIZE)
547+
548+ (rc, out) = cmd(["sha512sum", tmp_repack])
549+ if rc != 0:
550+ t = "error"
551+ s = "could not determine checksum of '%s'" % os.path.relpath(
552+ tmp_repack, tmpdir
553+ )
554+ self._add_result(t, n, s)
555+ return
556+ repack_sum = out.split()[0]
557+
558+ if orig_sum != repack_sum:
559+ if "SNAP_DEBUG_RESQUASHFS" in os.environ:
560+ print(self._debug_resquashfs(tmpdir, fn, tmp_repack), file=sys.stderr)
561+
562+ if os.environ["SNAP_DEBUG_RESQUASHFS"] == "2": # pragma: nocover
563+ import subprocess
564+
565+ print(
566+ "\nIn debug shell. tmpdir=%s, orig=%s, repack=%s"
567+ % (tmpdir, fn, tmp_repack)
568+ )
569+ subprocess.call(["bash"])
570+
571+ if "type" in self.snap_yaml and (
572+ self.snap_yaml["type"] == "base" or self.snap_yaml["type"] == "os"
573+ ):
574+ mksquash_opts = []
575+ for i in MKSQUASHFS_OPTS:
576+ if i not in mksquashfs_ignore_opts:
577+ mksquash_opts.append(i)
578+ else:
579+ mksquash_opts = MKSQUASHFS_OPTS
580+
581+ link = "https://forum.snapcraft.io/t/automated-reviews-and-snapcraft-2-38/4982/17"
582+ t = "error"
583+ s = (
584+ "checksums do not match. Please ensure the snap is "
585+ + "created with either 'snapcraft pack <DIR>' (using "
586+ + "snapcraft >= 2.38) or 'mksquashfs <dir> <snap> %s'"
587+ % " ".join(mksquash_opts)
588+ + " (using squashfs-tools >= 4.3). If using electron-builder, "
589+ "please upgrade to latest stable (>= 20.14.7). See %s "
590+ "for details." % link
591+ )
592+
593+ pkgname = self.snap_yaml["name"]
594+ if pkgname in sec_resquashfs_overrides:
595+ t = "info"
596+ s = "OK (check not enforced for this snap): %s" % s
597+
598+ # FIXME: fakeroot sporadically fails and saves the wrong
599+ # uid/gid/mode into its save file, thus causing the mksquashfs to
600+ # create the wrong file/perms/ownership. We want to not ignore this
601+ # error when using fakeroot. We need fakeroot or something like it
602+ # for unsquashfs to create devices, perms and ownership as
603+ # non-root, but only base and os snaps are allowed to have devices
604+ # and not use -all-root. Certain app snaps may also have
605+ # sec_mode_overrides for setuid/setgid files. Therefore, when not
606+ # using fakeroot, only enforce resquash for non-os/base snaps
607+ # and other snaps without sec_mode_overrides that specify
608+ # setuid/setgid. Eventually we'll fix fakeroot or do something else
609+ # so we can use this for all snaps, with or without -all-root.
610+ if "SNAP_FAKEROOT_RESQUASHFS" not in os.environ:
611+ if self.snap_yaml["type"] in ["base", "os"]:
612+ t = "info"
613+ s = "OK (check not enforced for base and os snaps)"
614+ link = None
615+ elif pkgname in sec_mode_overrides:
616+ has_sugid_override = False
617+ setugid_pat = re.compile(r"[sS]")
618+ for k in sec_mode_overrides[pkgname]:
619+ if isinstance(sec_mode_overrides[pkgname][k], list):
620+ for m in sec_mode_overrides[pkgname][k]:
621+ if setugid_pat.search(m):
622+ has_sugid_override = True
623+ break
624+ if has_sugid_override:
625+ break
626+ elif setugid_pat.search(sec_mode_overrides[pkgname][k]):
627+ has_sugid_override = True
628+ break
629+ if has_sugid_override:
630+ t = "info"
631+ s = (
632+ "OK (check not enforced for app snaps with "
633+ + "setuid/setgid overrides)"
634+ )
635+ link = None
636+
637+ self._add_result(t, n, s, link)
638+
639+
640+if __name__ == "__main__":
641+ # ComponentReview("tests/sample.comp", "tests/busybox-static-mvo_2.snap", None)
642+ componentReview = ComponentReview("tests/wrong_arch.comp", "tests/busybox-static-mvo_2.snap", None)
643+ print(componentReview.snap_yaml)
644diff --git a/reviewtools/schemas/component.json b/reviewtools/schemas/component.json
645new file mode 100644
646index 0000000..6822244
647--- /dev/null
648+++ b/reviewtools/schemas/component.json
649@@ -0,0 +1,63 @@
650+{
651+ "type": "object",
652+ "properties": {
653+ "component": {
654+ "type": "string",
655+ "pattern": "^(?=.{2,40}\\+.{2,40}$)^((?:[a-z0-9]+-?)*[a-z](?:-?[a-z0-9])*)\\+((?:[a-z0-9]+-?)*[a-z](?:-?[a-z0-9])*)$"
656+ },
657+ "type": {
658+ "type": "string",
659+ "enum": ["kernel-modules", "test"]
660+ },
661+ "architectures": {
662+ "type": "array",
663+ "uniqueItems": true,
664+ "items": {
665+ "type": "string",
666+ "enum": ["all", "amd64", "arm64", "armhf", "i386", "powerpc", "ppc64el", "riscv64", "s390x"],
667+ "minLength": 1
668+ },
669+ "minContains": 1
670+ },
671+ "summary": {
672+ "type": "string",
673+ "maxLength": 128
674+ },
675+ "description": {
676+ "type": "string",
677+ "maxLength": 4096
678+ },
679+ "version": {
680+ "type": "string",
681+ "description": "Snap version according to https://forum.snapcraft.io/t/snapd-enforcing-snap-versions/3974",
682+ "pattern": "^[a-zA-Z0-9](?:[a-zA-Z0-9:.+~-]{0,30}[a-zA-Z0-9+~])?$"
683+ },
684+ "hooks": {
685+ "type": "object",
686+ "propertyNames": {
687+ "enum": ["install", "pre-refresh", "post-refresh", "remove"]
688+ },
689+ "additionalProperties": {
690+ "$ref": "file:reviewtools/schemas/hook.json"
691+ }
692+ }
693+ },
694+ "additionalProperties": false,
695+
696+ "if": {
697+ "properties": {
698+ "type": {
699+ "const": "kernel-modules"
700+ }
701+ },
702+ "required": ["type"]
703+ },
704+ "then": {
705+ "required": ["component", "type", "summary", "description", "architectures"]
706+ },
707+ "else": {
708+ "required": ["component", "type", "summary", "description"]
709+ }
710+}
711+
712+
713diff --git a/reviewtools/schemas/hook.json b/reviewtools/schemas/hook.json
714new file mode 100644
715index 0000000..f2e21c8
716--- /dev/null
717+++ b/reviewtools/schemas/hook.json
718@@ -0,0 +1,50 @@
719+{
720+ "type": "object",
721+ "properties": {
722+ "plugs": {
723+ "type": "array",
724+ "uniqueItems": true,
725+ "items": {
726+ "type": ["string", "object"],
727+ "minLength": 1
728+ },
729+ "minItems": 1
730+ },
731+ "command_chain": {
732+ "type": "array",
733+ "uniqueItems": true,
734+ "items": {
735+ "type": "string",
736+ "minLength": 1
737+ },
738+ "minItems": 1
739+ },
740+ "environment": {
741+ "$comment": "See https://pubs.opengroup.org/onlinepubs/000095399/basedefs/xbd_chap08.html",
742+ "type": "object",
743+ "patternProperties": {
744+ "^[a-zA-Z_][a-zA-Z0-9_]*$": {
745+ "type": "string",
746+ "pattern": "^[^\\0]*$",
747+ "minLength": 1
748+ }
749+ },
750+ "additionalProperties": false,
751+ "minProperties": 1
752+ },
753+ "passthrough": {
754+ "type": "object",
755+ "additionalProperties": {
756+ "type": "array",
757+ "uniqueItems": true,
758+ "items": {
759+ "type": "string",
760+ "minLength": 1
761+ },
762+ "minItems": 1
763+ },
764+ "minProperties": 1
765+ }
766+ },
767+ "additionalProperties": false
768+}
769diff --git a/reviewtools/sr_common.py b/reviewtools/sr_common.py
770index 4d33459..dc445bf 100644
771--- a/reviewtools/sr_common.py
772+++ b/reviewtools/sr_common.py
773@@ -68,6 +68,7 @@ class SnapReview(Review):
774 "slots",
775 "system-usernames",
776 "links",
777+ "components",
778 ]
779
780 snap_manifest_required = {"build-packages": []}
781diff --git a/reviewtools/tests/test_cr_component.py b/reviewtools/tests/test_cr_component.py
782new file mode 100644
783index 0000000..4cfdd7c
784--- /dev/null
785+++ b/reviewtools/tests/test_cr_component.py
786@@ -0,0 +1,159 @@
787+# Author: Jorge Sancho Larraz <jorge.sancho.larraz@canonical.com>
788+# Copyright (C) 2024 Canonical Ltd.
789+#
790+# This program is free software: you can redistribute it and/or modify
791+# it under the terms of the GNU General Public License as published by
792+# the Free Software Foundation; version 3 of the License.
793+#
794+# This program is distributed in the hope that it will be useful,
795+# but WITHOUT ANY WARRANTY; without even the implied warranty of
796+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
797+# GNU General Public License for more details.
798+#
799+# You should have received a copy of the GNU General Public License
800+# along with this program. If not, see <http://www.gnu.org/licenses/>.
801+
802+import os
803+import tempfile
804+import shutil
805+import yaml
806+from reviewtools.cr_component import ComponentReview
807+from reviewtools.common import cleanup_unpack
808+from reviewtools.sr_tests import TestSnapReview
809+from subprocess import run
810+
811+
812+class TestComponent(TestSnapReview):
813+
814+ def setUp(self):
815+
816+ # Ensure YAMLs are properly initialized on each test
817+ self.snap_yaml = {
818+ "name": "test-snap",
819+ "version": "0.1",
820+ "description": "Test description",
821+ "summary": "Test summary",
822+ "architectures": ["all"],
823+ "apps": {
824+ "bar": {
825+ "command": "bin/bar"
826+ }
827+ }
828+ }
829+
830+ self.component_yaml = {
831+ "component": "test-snap+test-component",
832+ "type": "test",
833+ "architectures": ["all"],
834+ "summary": "Test summary",
835+ "description": "Test description",
836+ "version": "0.1",
837+ }
838+
839+ self.tmp_dir = tempfile.mkdtemp()
840+ self._create_snap(self.snap_yaml)
841+ self._create_component(self.component_yaml)
842+
843+ def tearDown(self):
844+ cleanup_unpack()
845+ shutil.rmtree(self.tmp_dir)
846+
847+ def _create_snap(self, snap_yaml, hooks=[]):
848+ if os.path.exists(os.path.join(self.tmp_dir, "snap")):
849+ shutil.rmtree(os.path.join(self.tmp_dir, "snap"))
850+ os.makedirs(os.path.join(self.tmp_dir, "snap/meta"))
851+ with open(os.path.join(self.tmp_dir, "snap/meta/snap.yaml"), "w+") as fd:
852+ fd.write(yaml.dump(snap_yaml))
853+ os.makedirs(os.path.join(self.tmp_dir, "snap/meta/hooks"))
854+ for hook in hooks:
855+ with open(os.path.join(self.tmp_dir, "component/meta/hooks/", hook), "w+") as fd:
856+ fd.write("#!/bin/bash\n")
857+ if os.path.exists(os.path.join(self.tmp_dir, "test.snap")):
858+ os.unlink(os.path.join(self.tmp_dir, "test.snap"))
859+ run(["mksquashfs", os.path.join(self.tmp_dir, "snap"), os.path.join(self.tmp_dir, "test.snap")])
860+
861+ def _create_component(self, component_yaml, hooks=[]):
862+ if os.path.exists(os.path.join(self.tmp_dir, "component")):
863+ shutil.rmtree(os.path.join(self.tmp_dir, "component"))
864+ os.makedirs(os.path.join(self.tmp_dir, "component/meta"))
865+ with open(os.path.join(self.tmp_dir, "component/meta/component.yaml"), "w+") as fd:
866+ fd.write(yaml.dump(component_yaml))
867+ os.makedirs(os.path.join(self.tmp_dir, "component/meta/hooks"))
868+ for hook in hooks:
869+ with open(os.path.join(self.tmp_dir, "component/meta/hooks/", hook), "w+") as fd:
870+ fd.write("#!/bin/bash\n")
871+ if os.path.exists(os.path.join(self.tmp_dir, "test.comp")):
872+ os.unlink(os.path.join(self.tmp_dir, "test.comp"))
873+ run(["mksquashfs", os.path.join(self.tmp_dir, "component"), os.path.join(self.tmp_dir, "test.comp")])
874+
875+ def test_component_schema__happy(self):
876+ c = ComponentReview(os.path.join(self.tmp_dir, "test.comp"), os.path.join(self.tmp_dir, "test.snap"))
877+ c.check_component_schema()
878+ r = c.review_report
879+ expected_counts = {"info": 1, "warn": 0, "error": 0}
880+ self.check_results(r, expected_counts)
881+
882+ def test_component_schema__invalid_title(self):
883+ self.component_yaml["title"] = "a" * 41
884+ self._create_component(self.snap_yaml)
885+ c = ComponentReview(os.path.join(self.tmp_dir, "test.comp"), os.path.join(self.tmp_dir, "test.snap"))
886+ c.check_component_schema()
887+ r = c.review_report
888+ expected_counts = {"info": 0, "warn": 0, "error": 1}
889+ self.check_results(r, expected_counts)
890+
891+ def test_component_to_snap_relation__happy(self):
892+ c = ComponentReview(os.path.join(self.tmp_dir, "test.comp"), os.path.join(self.tmp_dir, "test.snap"))
893+ c.check_component_to_snap_relation()
894+ r = c.review_report
895+ expected_counts = {"info": 1, "warn": 0, "error": 0}
896+ self.check_results(r, expected_counts)
897+
898+ def test_component_to_snap_relation__invalid(self):
899+ self.snap_yaml["name"] = "invalid-name"
900+ self._create_snap(self.snap_yaml)
901+ c = ComponentReview(os.path.join(self.tmp_dir, "test.comp"), os.path.join(self.tmp_dir, "test.snap"))
902+ c.check_component_to_snap_relation()
903+ r = c.review_report
904+ expected_counts = {"info": 0, "warn": 0, "error": 1}
905+ self.check_results(r, expected_counts)
906+
907+ def test_snap_to_component_relation__happy(self):
908+ self.snap_yaml["components"] = {}
909+ self.snap_yaml["components"]["test-component"] = self.component_yaml
910+ self._create_snap(self.snap_yaml)
911+ c = ComponentReview(os.path.join(self.tmp_dir, "test.comp"), os.path.join(self.tmp_dir, "test.snap"))
912+ c.check_snap_to_component_relation()
913+ r = c.review_report
914+ expected_counts = {"info": 1, "warn": 0, "error": 0}
915+ self.check_results(r, expected_counts)
916+
917+ def test_snap_to_component_relation__missing(self):
918+ c = ComponentReview(os.path.join(self.tmp_dir, "test.comp"), os.path.join(self.tmp_dir, "test.snap"))
919+ c.check_snap_to_component_relation()
920+ r = c.review_report
921+ expected_counts = {"info": 0, "warn": 0, "error": 1}
922+ self.check_results(r, expected_counts)
923+
924+ def test_hooks__no_hook(self):
925+ c = ComponentReview(os.path.join(self.tmp_dir, "test.comp"), os.path.join(self.tmp_dir, "test.snap"))
926+ c.check_hooks()
927+ r = c.review_report
928+ expected_counts = {"info": 0, "warn": 0, "error": 0}
929+ self.check_results(r, expected_counts)
930+
931+ def test_hooks__good_hook(self):
932+ self._create_component(self.component_yaml, hooks=["install"])
933+ c = ComponentReview(os.path.join(self.tmp_dir, "test.comp"), os.path.join(self.tmp_dir, "test.snap"))
934+ c.check_hooks()
935+ r = c.review_report
936+ expected_counts = {"info": 0, "warn": 0, "error": 0}
937+ self.check_results(r, expected_counts)
938+
939+ def test_hooks__invalid_hook(self):
940+ self._create_component(self.component_yaml, hooks=["invalid_hook"])
941+ c = ComponentReview(os.path.join(self.tmp_dir, "test.comp"), os.path.join(self.tmp_dir, "test.snap"))
942+ c.check_hooks()
943+ r = c.review_report
944+ expected_counts = {"info": 0, "warn": 0, "error": 1}
945+ self.check_results(r, expected_counts)
946diff --git a/reviewtools/tests/test_schema_base.py b/reviewtools/tests/test_schema_base.py
947new file mode 100644
948index 0000000..ed5b7a3
949--- /dev/null
950+++ b/reviewtools/tests/test_schema_base.py
951@@ -0,0 +1,44 @@
952+import json
953+import jsonschema
954+import copy
955+import unittest
956+
957+
958+class SafeDict(dict):
959+ def __missing__(self, key):
960+ return '{' + key + '}'
961+
962+
963+class TestSchemaBase(unittest.TestCase):
964+
965+ yaml = {}
966+ schema_file = "reviewtools/schemas/****.json"
967+
968+ def setUp(self):
969+
970+ with open(self.schema_file) as fd:
971+ self.schema = json.loads(fd.read())
972+
973+ def _validate(self, yaml, schema):
974+ try:
975+ jsonschema.validate(yaml, schema)
976+ return None
977+ except jsonschema.ValidationError as e:
978+ return e.message
979+
980+ def _test_base(self):
981+ error = self._validate(self.yaml, self.schema)
982+ self.assertEqual(None, error)
983+
984+ def _test_value(self, key, value, expected_error):
985+ yaml = copy.deepcopy(self.yaml)
986+ if value is None:
987+ if key in yaml:
988+ del yaml[key]
989+ else:
990+ yaml[key] = value
991+ error = self._validate(yaml, self.schema)
992+ if expected_error is None:
993+ self.assertIsNone(error)
994+ else:
995+ self.assertIn(expected_error, error)
996diff --git a/reviewtools/tests/test_schema_component.py b/reviewtools/tests/test_schema_component.py
997new file mode 100644
998index 0000000..ee2280f
999--- /dev/null
1000+++ b/reviewtools/tests/test_schema_component.py
1001@@ -0,0 +1,145 @@
1002+
1003+from reviewtools.tests.test_schema_base import TestSchemaBase, SafeDict
1004+import copy
1005+
1006+
1007+class TestSchemaComponent(TestSchemaBase):
1008+
1009+ required_properties = ["component", "type", "summary", "description"]
1010+ optional_properties = ["architectures", "version", "hooks"]
1011+
1012+ schema_file = "reviewtools/schemas/component.json"
1013+ yaml = {
1014+ "component": "snap+component",
1015+ "type": "test",
1016+ "architectures": ["amd64", "arm64"],
1017+ "summary": "The super cat generator",
1018+ "description": "A more in-depth look at what your snap does and who may find it most useful.",
1019+ "version": "2.01",
1020+ "hooks": {
1021+ "install": {
1022+ "plugs": ["test_plug"]
1023+ },
1024+ "remove": {
1025+ "plugs": ["test_plug"]
1026+ }
1027+ }
1028+ }
1029+
1030+ def test_base(self):
1031+ self._test_base()
1032+
1033+ def test_component(self):
1034+ for value, error in [
1035+ ("test-snap+test-component", None),
1036+ ("a", "'{value}' does not match "),
1037+ ("a" * 41, "'{value}' does not match "),
1038+ ("-aaa", "'{value}' does not match "),
1039+ ("aaa-", "'{value}' does not match "),
1040+ ("000", "'{value}' does not match "),
1041+ ("aa--aa", "'{value}' does not match "),
1042+ ("aa%a", "'{value}' does not match "),
1043+ (2, "{value} is not of type 'string'"),
1044+ ([], "{value} is not of type 'string'")
1045+ ]:
1046+ with self.subTest(value=value):
1047+ error = error.format_map(SafeDict(value=value)) if error else None
1048+ self._test_value("component", value, error)
1049+
1050+ def test_type(self):
1051+ for value, error in [
1052+ ("kernel-modules", None),
1053+ ("invalid_type", "'{value}' is not one of "),
1054+ (2, "{value} is not of type 'string'"),
1055+ ([], "{value} is not of type 'string'")
1056+ ]:
1057+ with self.subTest(value=value):
1058+ error = error.format_map(SafeDict(value=value)) if error else None
1059+ self._test_value("type", value, error)
1060+
1061+ def test_architectures(self):
1062+ for value, error in [
1063+ (["amd64"], None),
1064+ (["invalid_architecture"], "'{value}' is not one of "),
1065+ ([2], "{value} is not of type 'string'"),
1066+ ([[]], "{value} is not of type 'string'"),
1067+ (2, "{value} is not of type 'array'"),
1068+ # ([], "{value} is not of type 'array'") # Should we allow empty lists?
1069+ ]:
1070+ with self.subTest(value=value):
1071+ error = error.format_map(SafeDict(value=value[0] if isinstance(value, list) and len(value) == 1 else value)) if error else None
1072+ self._test_value("architectures", value, error)
1073+
1074+ def test_summary(self):
1075+ for value, error in [
1076+ ("My good summary", None),
1077+ ("a" * 129, "'{value}' is too long"),
1078+ (2, "{value} is not of type 'string'"),
1079+ ([], "{value} is not of type 'string'")
1080+ ]:
1081+ with self.subTest(value=value):
1082+ error = error.format_map(SafeDict(value=value)) if error else None
1083+ self._test_value("summary", value, error)
1084+
1085+ def test_description(self):
1086+ for value, error in [
1087+ ("My good description", None),
1088+ ("a" * 4097, "'{value}' is too long"),
1089+ (2, "{value} is not of type 'string'"),
1090+ ([], "{value} is not of type 'string'")
1091+ ]:
1092+ with self.subTest(value=value):
1093+ error = error.format_map(SafeDict(value=value)) if error else None
1094+ self._test_value("description", value, error)
1095+
1096+ def test_version(self):
1097+ for value, error in [
1098+ ("2.0", None),
1099+ ("~2.90", "'{value}' does not match "),
1100+ (2, "{value} is not of type 'string'"),
1101+ ([], "{value} is not of type 'string'")
1102+ ]:
1103+ with self.subTest(value=value):
1104+ error = error.format_map(SafeDict(value=value)) if error else None
1105+ self._test_value("version", value, error)
1106+
1107+ def test_hook(self):
1108+ for value, error in [
1109+ ({"install": {"plugs": ["test_plug"]}}, None),
1110+ ({"invalid-hook": {"plugs": ["test_plug"]}}, "'invalid-hook' is not one of "),
1111+ ({"install": {"invalid-attribute": ["test_plug"]}}, "Additional properties are not allowed ('invalid-attribute' was unexpected)"),
1112+ (2, "{value} is not of type 'object'"),
1113+ ([], "{value} is not of type 'object'")
1114+ ]:
1115+ with self.subTest(value=value):
1116+ error = error.format_map(SafeDict(value=value)) if error else None
1117+ self._test_value("hooks", value, error)
1118+
1119+ def test_required_properties(self):
1120+ for property in self.required_properties:
1121+ with self.subTest(property=property):
1122+ yaml = copy.deepcopy(self.yaml)
1123+ del yaml[property]
1124+ error = self._validate(yaml, self.schema)
1125+ self.assertEqual("'%s' is a required property" % property, error)
1126+
1127+ def test_optional_properties(self):
1128+ for property in self.optional_properties:
1129+ with self.subTest(property=property):
1130+ yaml = copy.deepcopy(self.yaml)
1131+ del yaml[property]
1132+ error = self._validate(yaml, self.schema)
1133+ self.assertEqual(None, error)
1134+
1135+ def test_unsupported_properties(self):
1136+ yaml = copy.deepcopy(self.yaml)
1137+ yaml["wrong_property"] = "wrong_property"
1138+ error = self._validate(yaml, self.schema)
1139+ self.assertEqual("Additional properties are not allowed ('wrong_property' was unexpected)", error)
1140+
1141+ def test_missing_architectures_on_kernel_modules(self):
1142+ yaml = copy.deepcopy(self.yaml)
1143+ yaml["type"] = "kernel-modules"
1144+ del yaml["architectures"]
1145+ error = self._validate(yaml, self.schema)
1146+ self.assertEqual("'architectures' is a required property", error)
1147diff --git a/reviewtools/tests/test_schema_hook.py b/reviewtools/tests/test_schema_hook.py
1148new file mode 100644
1149index 0000000..c98b2ad
1150--- /dev/null
1151+++ b/reviewtools/tests/test_schema_hook.py
1152@@ -0,0 +1,71 @@
1153+from reviewtools.tests.test_schema_base import TestSchemaBase, SafeDict
1154+
1155+
1156+class TestSchemaHook(TestSchemaBase):
1157+
1158+ schema_file = "reviewtools/schemas/hook.json"
1159+ schema = {}
1160+ yaml = {}
1161+
1162+ def test_base(self):
1163+ self._test_base()
1164+
1165+ # TDOO: Tests plugs better when interfaces schema will be implemented
1166+ def test_plugs(self):
1167+ for value, error in [
1168+ (["foo"], None),
1169+ ([{}], None),
1170+ ([], "{value} is too short"),
1171+ (2, "{value} is not of type 'array'"),
1172+ ([2], "{value} is not of type 'string', 'object'"),
1173+ (None, None),
1174+ ]:
1175+ with self.subTest(value=value):
1176+ error = error.format_map(SafeDict(value=value[0] if isinstance(value, list) and len(value) == 1 else value)) if error else None
1177+ self._test_value("plugs", value, error)
1178+
1179+ def test_command_chain(self):
1180+ for value, error in [
1181+ (["foo"], None),
1182+ ([], "{value} is too short"),
1183+ (2, "{value} is not of type 'array'"),
1184+ ([2], "{value} is not of type 'string'"),
1185+ (None, None),
1186+ ]:
1187+ with self.subTest(value=value):
1188+ error = error.format_map(SafeDict(value=value[0] if isinstance(value, list) and len(value) == 1 else value)) if error else None
1189+ self._test_value("command_chain", value, error)
1190+
1191+ def test_environment(self):
1192+ for value, error in [
1193+ ({}, "{value} does not have enough properties"),
1194+ ({"ENV_NAME": ""}, "'' is too short"),
1195+ ({"ENV-NAME": ""}, "'ENV-NAME' does not match any of the regexes: '^[a-zA-Z_][a-zA-Z0-9_]*$'"),
1196+ ({"ENV_NAME": "value"}, None),
1197+ ({"ENV_NAME": "val\0ue"}, "'val\\x00ue' does not match '^[^\\\\0]*$'"),
1198+ ([], "{value} is not of type 'object'"),
1199+ (2, "{value} is not of type 'object'"),
1200+ ([2], "{value} is not of type 'object'"),
1201+ (None, None),
1202+ ]:
1203+ with self.subTest(value=value):
1204+ error = error.format_map(SafeDict(value=value)) if error else None
1205+ self._test_value("environment", value, error)
1206+
1207+ def test_passthrough(self):
1208+ for value, error in [
1209+ ({}, "{value} does not have enough properties"),
1210+ ({"test": ''}, "'' is not of type 'array'"),
1211+ ({"test": []}, "[] is too short"),
1212+ ({"test": ['']}, "'' is too short"),
1213+ ({"test": ["value"]}, None),
1214+ ({"te%st": ["value"]}, None),
1215+ ({"test": ["val\0ue"]}, None),
1216+ ([], "{value} is not of type 'object'"),
1217+ (2, "{value} is not of type 'object'"),
1218+ ([2], "{value} is not of type 'object'"),
1219+ (None, None),
1220+ ]:
1221+ with self.subTest(value=value):
1222+ error = error.format_map(SafeDict(value=value)) if error else None
1223+ self._test_value("passthrough", value, error)

Subscribers

People subscribed via source and target branches