Feature: installed file verification (#12841)
This feature generates a verification manifest for each installed package and provides a command, "spack verify", which can be used to compare the current file checksums/permissions with those calculated at installed time. Verification includes * Checksums of files * File permissions * Modification time * File size Packages installed before this PR will be skipped during verification. To verify such a package you must reinstall it. The spack verify command has three modes. * With the -a,--all option it will check every installed package. * With the -f,--files option, it will check some specific files, determine which package they belong to, and confirm that they have not been changed. * With the -s,--specs option or by default, it will check some specific packages that no files havae changed.
This commit is contained in:
parent
5ea0eed287
commit
94e80933f0
@ -277,6 +277,40 @@ the tarballs in question to it (see :ref:`mirrors`):
|
|||||||
|
|
||||||
$ spack install galahad
|
$ spack install galahad
|
||||||
|
|
||||||
|
-----------------------
|
||||||
|
Verifying installations
|
||||||
|
-----------------------
|
||||||
|
|
||||||
|
The ``spack verify`` command can be used to verify the validity of
|
||||||
|
Spack-installed packages any time after installation.
|
||||||
|
|
||||||
|
At installation time, Spack creates a manifest of every file in the
|
||||||
|
installation prefix. For links, Spack tracks the mode, ownership, and
|
||||||
|
destination. For directories, Spack tracks the mode, and
|
||||||
|
ownership. For files, Spack tracks the mode, ownership, modification
|
||||||
|
time, hash, and size. The Spack verify command will check, for every
|
||||||
|
file in each package, whether any of those attributes have changed. It
|
||||||
|
will also check for newly added files or deleted files from the
|
||||||
|
installation prefix. Spack can either check all installed packages
|
||||||
|
using the `-a,--all` or accept specs listed on the command line to
|
||||||
|
verify.
|
||||||
|
|
||||||
|
The ``spack verify`` command can also verify for individual files that
|
||||||
|
they haven't been altered since installation time. If the given file
|
||||||
|
is not in a Spack installation prefix, Spack will report that it is
|
||||||
|
not owned by any package. To check individual files instead of specs,
|
||||||
|
use the ``-f,--files`` option.
|
||||||
|
|
||||||
|
Spack installation manifests are part of the tarball signed by Spack
|
||||||
|
for binary package distribution. When installed from a binary package,
|
||||||
|
Spack uses the packaged installation manifest instead of creating one
|
||||||
|
at install time.
|
||||||
|
|
||||||
|
The ``spack verify`` command also accepts the ``-l,--local`` option to
|
||||||
|
check only local packages (as opposed to those used transparently from
|
||||||
|
``upstream`` spack instances) and the ``-j,--json`` option to output
|
||||||
|
machine-readable json data for any errors.
|
||||||
|
|
||||||
-------------------------
|
-------------------------
|
||||||
Seeing installed packages
|
Seeing installed packages
|
||||||
-------------------------
|
-------------------------
|
||||||
|
@ -653,7 +653,7 @@ def replace_directory_transaction(directory_name, tmp_root=None):
|
|||||||
tty.debug('TEMPORARY DIRECTORY DELETED [{0}]'.format(tmp_dir))
|
tty.debug('TEMPORARY DIRECTORY DELETED [{0}]'.format(tmp_dir))
|
||||||
|
|
||||||
|
|
||||||
def hash_directory(directory):
|
def hash_directory(directory, ignore=[]):
|
||||||
"""Hashes recursively the content of a directory.
|
"""Hashes recursively the content of a directory.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@ -670,6 +670,7 @@ def hash_directory(directory):
|
|||||||
for root, dirs, files in os.walk(directory):
|
for root, dirs, files in os.walk(directory):
|
||||||
for name in sorted(files):
|
for name in sorted(files):
|
||||||
filename = os.path.join(root, name)
|
filename = os.path.join(root, name)
|
||||||
|
if filename not in ignore:
|
||||||
# TODO: if caching big files becomes an issue, convert this to
|
# TODO: if caching big files becomes an issue, convert this to
|
||||||
# TODO: read in chunks. Currently it's used only for testing
|
# TODO: read in chunks. Currently it's used only for testing
|
||||||
# TODO: purposes.
|
# TODO: purposes.
|
||||||
|
@ -585,8 +585,13 @@ def extract_tarball(spec, filename, allow_root=False, unsigned=False,
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
shutil.rmtree(spec.prefix)
|
shutil.rmtree(spec.prefix)
|
||||||
tty.die(e)
|
tty.die(e)
|
||||||
# Delay creating spec.prefix until verification is complete
|
else:
|
||||||
# and any relocation has been done.
|
manifest_file = os.path.join(spec.prefix,
|
||||||
|
spack.store.layout.metadata_dir,
|
||||||
|
spack.store.layout.manifest_file_name)
|
||||||
|
if not os.path.exists(manifest_file):
|
||||||
|
spec_id = spec.format('{name}/{hash:7}')
|
||||||
|
tty.warn('No manifest file in tarball for spec %s' % spec_id)
|
||||||
finally:
|
finally:
|
||||||
shutil.rmtree(tmpdir)
|
shutil.rmtree(tmpdir)
|
||||||
|
|
||||||
|
@ -174,15 +174,19 @@ def elide_list(line_list, max_num=10):
|
|||||||
return line_list
|
return line_list
|
||||||
|
|
||||||
|
|
||||||
def disambiguate_spec(spec, env):
|
def disambiguate_spec(spec, env, local=False):
|
||||||
"""Given a spec, figure out which installed package it refers to.
|
"""Given a spec, figure out which installed package it refers to.
|
||||||
|
|
||||||
Arguments:
|
Arguments:
|
||||||
spec (spack.spec.Spec): a spec to disambiguate
|
spec (spack.spec.Spec): a spec to disambiguate
|
||||||
env (spack.environment.Environment): a spack environment,
|
env (spack.environment.Environment): a spack environment,
|
||||||
if one is active, or None if no environment is active
|
if one is active, or None if no environment is active
|
||||||
|
local (boolean, default False): do not search chained spack instances
|
||||||
"""
|
"""
|
||||||
hashes = env.all_hashes() if env else None
|
hashes = env.all_hashes() if env else None
|
||||||
|
if local:
|
||||||
|
matching_specs = spack.store.db.query_local(spec, hashes=hashes)
|
||||||
|
else:
|
||||||
matching_specs = spack.store.db.query(spec, hashes=hashes)
|
matching_specs = spack.store.db.query(spec, hashes=hashes)
|
||||||
if not matching_specs:
|
if not matching_specs:
|
||||||
tty.die("Spec '%s' matches no installed packages." % spec)
|
tty.die("Spec '%s' matches no installed packages." % spec)
|
||||||
|
95
lib/spack/spack/cmd/verify.py
Normal file
95
lib/spack/spack/cmd/verify.py
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
# Copyright 2013-2019 Lawrence Livermore National Security, LLC and other
|
||||||
|
# Spack Project Developers. See the top-level COPYRIGHT file for details.
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: (Apache-2.0 OR MIT)
|
||||||
|
from __future__ import print_function
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
import llnl.util.tty as tty
|
||||||
|
|
||||||
|
import spack.store
|
||||||
|
import spack.verify
|
||||||
|
import spack.environment as ev
|
||||||
|
|
||||||
|
description = "Check that all spack packages are on disk as installed"
|
||||||
|
section = "admin"
|
||||||
|
level = "long"
|
||||||
|
|
||||||
|
|
||||||
|
def setup_parser(subparser):
|
||||||
|
setup_parser.parser = subparser
|
||||||
|
|
||||||
|
subparser.add_argument('-l', '--local', action='store_true',
|
||||||
|
help="Verify only locally installed packages")
|
||||||
|
subparser.add_argument('-j', '--json', action='store_true',
|
||||||
|
help="Ouptut json-formatted errors")
|
||||||
|
subparser.add_argument('-a', '--all', action='store_true',
|
||||||
|
help="Verify all packages")
|
||||||
|
subparser.add_argument('files_or_specs', nargs=argparse.REMAINDER,
|
||||||
|
help="Files or specs to verify")
|
||||||
|
|
||||||
|
type = subparser.add_mutually_exclusive_group()
|
||||||
|
type.add_argument(
|
||||||
|
'-s', '--specs',
|
||||||
|
action='store_const', const='specs', dest='type', default='specs',
|
||||||
|
help='Treat entries as specs (default)')
|
||||||
|
type.add_argument(
|
||||||
|
'-f', '--files',
|
||||||
|
action='store_const', const='files', dest='type', default='specs',
|
||||||
|
help="Treat entries as absolute filenames. Cannot be used with '-a'")
|
||||||
|
|
||||||
|
|
||||||
|
def verify(parser, args):
|
||||||
|
local = args.local
|
||||||
|
|
||||||
|
if args.type == 'files':
|
||||||
|
if args.all:
|
||||||
|
setup_parser.parser.print_help()
|
||||||
|
return 1
|
||||||
|
|
||||||
|
for file in args.files_or_specs:
|
||||||
|
results = spack.verify.check_file_manifest(file)
|
||||||
|
if results.has_errors():
|
||||||
|
if args.json:
|
||||||
|
print(results.json_string())
|
||||||
|
else:
|
||||||
|
print(results)
|
||||||
|
|
||||||
|
return 0
|
||||||
|
else:
|
||||||
|
spec_args = spack.cmd.parse_specs(args.files_or_specs)
|
||||||
|
|
||||||
|
if args.all:
|
||||||
|
query = spack.store.db.query_local if local else spack.store.db.query
|
||||||
|
|
||||||
|
# construct spec list
|
||||||
|
if spec_args:
|
||||||
|
spec_list = spack.cmd.parse_specs(args.files_or_specs)
|
||||||
|
specs = []
|
||||||
|
for spec in spec_list:
|
||||||
|
specs += query(spec, installed=True)
|
||||||
|
else:
|
||||||
|
specs = query(installed=True)
|
||||||
|
|
||||||
|
elif args.files_or_specs:
|
||||||
|
# construct disambiguated spec list
|
||||||
|
env = ev.get_env(args, 'verify')
|
||||||
|
specs = list(map(lambda x: spack.cmd.disambiguate_spec(x, env,
|
||||||
|
local=local),
|
||||||
|
spec_args))
|
||||||
|
else:
|
||||||
|
setup_parser.parser.print_help()
|
||||||
|
return 1
|
||||||
|
|
||||||
|
for spec in specs:
|
||||||
|
tty.debug("Verifying package %s")
|
||||||
|
results = spack.verify.check_spec_manifest(spec)
|
||||||
|
if results.has_errors():
|
||||||
|
if args.json:
|
||||||
|
print(results.json_string())
|
||||||
|
else:
|
||||||
|
tty.msg("In package %s" % spec.format('{name}/{hash:7}'))
|
||||||
|
print(results)
|
||||||
|
return 1
|
||||||
|
else:
|
||||||
|
tty.debug(results)
|
@ -194,6 +194,7 @@ def __init__(self, root, **kwargs):
|
|||||||
self.spec_file_name = 'spec.yaml'
|
self.spec_file_name = 'spec.yaml'
|
||||||
self.extension_file_name = 'extensions.yaml'
|
self.extension_file_name = 'extensions.yaml'
|
||||||
self.packages_dir = 'repos' # archive of package.py files
|
self.packages_dir = 'repos' # archive of package.py files
|
||||||
|
self.manifest_file_name = 'install_manifest.json'
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def hidden_file_paths(self):
|
def hidden_file_paths(self):
|
||||||
@ -430,6 +431,11 @@ def _extension_map(self, spec):
|
|||||||
def _write_extensions(self, spec, extensions):
|
def _write_extensions(self, spec, extensions):
|
||||||
path = self.extension_file_path(spec)
|
path = self.extension_file_path(spec)
|
||||||
|
|
||||||
|
if not extensions:
|
||||||
|
# Remove the empty extensions file
|
||||||
|
os.remove(path)
|
||||||
|
return
|
||||||
|
|
||||||
# Create a temp file in the same directory as the actual file.
|
# Create a temp file in the same directory as the actual file.
|
||||||
dirname, basename = os.path.split(path)
|
dirname, basename = os.path.split(path)
|
||||||
mkdirp(dirname)
|
mkdirp(dirname)
|
||||||
|
@ -188,39 +188,17 @@ def __init__(self, root, layout, **kwargs):
|
|||||||
|
|
||||||
# Super class gets projections from the kwargs
|
# Super class gets projections from the kwargs
|
||||||
# YAML specific to get projections from YAML file
|
# YAML specific to get projections from YAML file
|
||||||
projections_path = os.path.join(self._root, _projections_path)
|
self.projections_path = os.path.join(self._root, _projections_path)
|
||||||
if not self.projections:
|
if not self.projections:
|
||||||
if os.path.exists(projections_path):
|
|
||||||
# Read projections file from view
|
# Read projections file from view
|
||||||
with open(projections_path, 'r') as f:
|
self.projections = self.read_projections()
|
||||||
projections_data = s_yaml.load(f)
|
elif not os.path.exists(self.projections_path):
|
||||||
spack.config.validate(projections_data,
|
|
||||||
spack.schema.projections.schema)
|
|
||||||
self.projections = projections_data['projections']
|
|
||||||
else:
|
|
||||||
# Write projections file to new view
|
# Write projections file to new view
|
||||||
# Not strictly necessary as the empty file is the empty
|
self.write_projections()
|
||||||
# projection but it makes sense for consistency
|
|
||||||
try:
|
|
||||||
mkdirp(os.path.dirname(projections_path))
|
|
||||||
with open(projections_path, 'w') as f:
|
|
||||||
f.write(s_yaml.dump({'projections': self.projections}))
|
|
||||||
except OSError as e:
|
|
||||||
if self.projections:
|
|
||||||
raise e
|
|
||||||
elif not os.path.exists(projections_path):
|
|
||||||
# Write projections file to new view
|
|
||||||
mkdirp(os.path.dirname(projections_path))
|
|
||||||
with open(projections_path, 'w') as f:
|
|
||||||
f.write(s_yaml.dump({'projections': self.projections}))
|
|
||||||
else:
|
else:
|
||||||
# Ensure projections are the same from each source
|
# Ensure projections are the same from each source
|
||||||
# Read projections file from view
|
# Read projections file from view
|
||||||
with open(projections_path, 'r') as f:
|
if self.projections != self.read_projections():
|
||||||
projections_data = s_yaml.load(f)
|
|
||||||
spack.config.validate(projections_data,
|
|
||||||
spack.schema.projections.schema)
|
|
||||||
if self.projections != projections_data['projections']:
|
|
||||||
msg = 'View at %s has projections file' % self._root
|
msg = 'View at %s has projections file' % self._root
|
||||||
msg += ' which does not match projections passed manually.'
|
msg += ' which does not match projections passed manually.'
|
||||||
raise ConflictingProjectionsError(msg)
|
raise ConflictingProjectionsError(msg)
|
||||||
@ -229,6 +207,22 @@ def __init__(self, root, layout, **kwargs):
|
|||||||
|
|
||||||
self._croot = colorize_root(self._root) + " "
|
self._croot = colorize_root(self._root) + " "
|
||||||
|
|
||||||
|
def write_projections(self):
|
||||||
|
if self.projections:
|
||||||
|
mkdirp(os.path.dirname(self.projections_path))
|
||||||
|
with open(self.projections_path, 'w') as f:
|
||||||
|
f.write(s_yaml.dump({'projections': self.projections}))
|
||||||
|
|
||||||
|
def read_projections(self):
|
||||||
|
if os.path.exists(self.projections_path):
|
||||||
|
with open(self.projections_path, 'r') as f:
|
||||||
|
projections_data = s_yaml.load(f)
|
||||||
|
spack.config.validate(projections_data,
|
||||||
|
spack.schema.projections.schema)
|
||||||
|
return projections_data['projections']
|
||||||
|
else:
|
||||||
|
return {}
|
||||||
|
|
||||||
def add_specs(self, *specs, **kwargs):
|
def add_specs(self, *specs, **kwargs):
|
||||||
assert all((s.concrete for s in specs))
|
assert all((s.concrete for s in specs))
|
||||||
specs = set(specs)
|
specs = set(specs)
|
||||||
|
@ -36,8 +36,14 @@ def all_hook_modules():
|
|||||||
mod_name = __name__ + '.' + name
|
mod_name = __name__ + '.' + name
|
||||||
path = os.path.join(spack.paths.hooks_path, name) + ".py"
|
path = os.path.join(spack.paths.hooks_path, name) + ".py"
|
||||||
mod = simp.load_source(mod_name, path)
|
mod = simp.load_source(mod_name, path)
|
||||||
|
|
||||||
|
if name == 'write_install_manifest':
|
||||||
|
last_mod = mod
|
||||||
|
else:
|
||||||
modules.append(mod)
|
modules.append(mod)
|
||||||
|
|
||||||
|
# put `write_install_manifest` as the last hook to run
|
||||||
|
modules.append(last_mod)
|
||||||
return modules
|
return modules
|
||||||
|
|
||||||
|
|
||||||
|
11
lib/spack/spack/hooks/write_install_manifest.py
Normal file
11
lib/spack/spack/hooks/write_install_manifest.py
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
# Copyright 2013-2019 Lawrence Livermore National Security, LLC and other
|
||||||
|
# Spack Project Developers. See the top-level COPYRIGHT file for details.
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: (Apache-2.0 OR MIT)
|
||||||
|
|
||||||
|
import spack.verify
|
||||||
|
|
||||||
|
|
||||||
|
def post_install(spec):
|
||||||
|
if not spec.external:
|
||||||
|
spack.verify.write_manifest(spec)
|
@ -197,22 +197,26 @@ def test_install_overwrite(
|
|||||||
|
|
||||||
install('libdwarf')
|
install('libdwarf')
|
||||||
|
|
||||||
|
manifest = os.path.join(spec.prefix, spack.store.layout.metadata_dir,
|
||||||
|
spack.store.layout.manifest_file_name)
|
||||||
|
|
||||||
assert os.path.exists(spec.prefix)
|
assert os.path.exists(spec.prefix)
|
||||||
expected_md5 = fs.hash_directory(spec.prefix)
|
expected_md5 = fs.hash_directory(spec.prefix, ignore=[manifest])
|
||||||
|
|
||||||
# Modify the first installation to be sure the content is not the same
|
# Modify the first installation to be sure the content is not the same
|
||||||
# as the one after we reinstalled
|
# as the one after we reinstalled
|
||||||
with open(os.path.join(spec.prefix, 'only_in_old'), 'w') as f:
|
with open(os.path.join(spec.prefix, 'only_in_old'), 'w') as f:
|
||||||
f.write('This content is here to differentiate installations.')
|
f.write('This content is here to differentiate installations.')
|
||||||
|
|
||||||
bad_md5 = fs.hash_directory(spec.prefix)
|
bad_md5 = fs.hash_directory(spec.prefix, ignore=[manifest])
|
||||||
|
|
||||||
assert bad_md5 != expected_md5
|
assert bad_md5 != expected_md5
|
||||||
|
|
||||||
install('--overwrite', '-y', 'libdwarf')
|
install('--overwrite', '-y', 'libdwarf')
|
||||||
|
|
||||||
assert os.path.exists(spec.prefix)
|
assert os.path.exists(spec.prefix)
|
||||||
assert fs.hash_directory(spec.prefix) == expected_md5
|
assert fs.hash_directory(spec.prefix, ignore=[manifest]) == expected_md5
|
||||||
assert fs.hash_directory(spec.prefix) != bad_md5
|
assert fs.hash_directory(spec.prefix, ignore=[manifest]) != bad_md5
|
||||||
|
|
||||||
|
|
||||||
def test_install_overwrite_not_installed(
|
def test_install_overwrite_not_installed(
|
||||||
@ -242,11 +246,20 @@ def test_install_overwrite_multiple(
|
|||||||
|
|
||||||
install('cmake')
|
install('cmake')
|
||||||
|
|
||||||
|
ld_manifest = os.path.join(libdwarf.prefix,
|
||||||
|
spack.store.layout.metadata_dir,
|
||||||
|
spack.store.layout.manifest_file_name)
|
||||||
|
|
||||||
assert os.path.exists(libdwarf.prefix)
|
assert os.path.exists(libdwarf.prefix)
|
||||||
expected_libdwarf_md5 = fs.hash_directory(libdwarf.prefix)
|
expected_libdwarf_md5 = fs.hash_directory(libdwarf.prefix,
|
||||||
|
ignore=[ld_manifest])
|
||||||
|
|
||||||
|
cm_manifest = os.path.join(cmake.prefix,
|
||||||
|
spack.store.layout.metadata_dir,
|
||||||
|
spack.store.layout.manifest_file_name)
|
||||||
|
|
||||||
assert os.path.exists(cmake.prefix)
|
assert os.path.exists(cmake.prefix)
|
||||||
expected_cmake_md5 = fs.hash_directory(cmake.prefix)
|
expected_cmake_md5 = fs.hash_directory(cmake.prefix, ignore=[cm_manifest])
|
||||||
|
|
||||||
# Modify the first installation to be sure the content is not the same
|
# Modify the first installation to be sure the content is not the same
|
||||||
# as the one after we reinstalled
|
# as the one after we reinstalled
|
||||||
@ -255,8 +268,8 @@ def test_install_overwrite_multiple(
|
|||||||
with open(os.path.join(cmake.prefix, 'only_in_old'), 'w') as f:
|
with open(os.path.join(cmake.prefix, 'only_in_old'), 'w') as f:
|
||||||
f.write('This content is here to differentiate installations.')
|
f.write('This content is here to differentiate installations.')
|
||||||
|
|
||||||
bad_libdwarf_md5 = fs.hash_directory(libdwarf.prefix)
|
bad_libdwarf_md5 = fs.hash_directory(libdwarf.prefix, ignore=[ld_manifest])
|
||||||
bad_cmake_md5 = fs.hash_directory(cmake.prefix)
|
bad_cmake_md5 = fs.hash_directory(cmake.prefix, ignore=[cm_manifest])
|
||||||
|
|
||||||
assert bad_libdwarf_md5 != expected_libdwarf_md5
|
assert bad_libdwarf_md5 != expected_libdwarf_md5
|
||||||
assert bad_cmake_md5 != expected_cmake_md5
|
assert bad_cmake_md5 != expected_cmake_md5
|
||||||
@ -264,10 +277,13 @@ def test_install_overwrite_multiple(
|
|||||||
install('--overwrite', '-y', 'libdwarf', 'cmake')
|
install('--overwrite', '-y', 'libdwarf', 'cmake')
|
||||||
assert os.path.exists(libdwarf.prefix)
|
assert os.path.exists(libdwarf.prefix)
|
||||||
assert os.path.exists(cmake.prefix)
|
assert os.path.exists(cmake.prefix)
|
||||||
assert fs.hash_directory(libdwarf.prefix) == expected_libdwarf_md5
|
|
||||||
assert fs.hash_directory(cmake.prefix) == expected_cmake_md5
|
ld_hash = fs.hash_directory(libdwarf.prefix, ignore=[ld_manifest])
|
||||||
assert fs.hash_directory(libdwarf.prefix) != bad_libdwarf_md5
|
cm_hash = fs.hash_directory(cmake.prefix, ignore=[cm_manifest])
|
||||||
assert fs.hash_directory(cmake.prefix) != bad_cmake_md5
|
assert ld_hash == expected_libdwarf_md5
|
||||||
|
assert cm_hash == expected_cmake_md5
|
||||||
|
assert ld_hash != bad_libdwarf_md5
|
||||||
|
assert cm_hash != bad_cmake_md5
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.usefixtures(
|
@pytest.mark.usefixtures(
|
||||||
|
89
lib/spack/spack/test/cmd/verify.py
Normal file
89
lib/spack/spack/test/cmd/verify.py
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
# Copyright 2013-2019 Lawrence Livermore National Security, LLC and other
|
||||||
|
# Spack Project Developers. See the top-level COPYRIGHT file for details.
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: (Apache-2.0 OR MIT)
|
||||||
|
|
||||||
|
"""Tests for the `spack verify` command"""
|
||||||
|
import os
|
||||||
|
|
||||||
|
import llnl.util.filesystem as fs
|
||||||
|
|
||||||
|
import spack.util.spack_json as sjson
|
||||||
|
import spack.verify
|
||||||
|
import spack.spec
|
||||||
|
import spack.store
|
||||||
|
from spack.main import SpackCommand
|
||||||
|
|
||||||
|
verify = SpackCommand('verify')
|
||||||
|
install = SpackCommand('install')
|
||||||
|
|
||||||
|
|
||||||
|
def test_single_file_verify_cmd(tmpdir):
|
||||||
|
# Test the verify command interface to verifying a single file.
|
||||||
|
filedir = os.path.join(str(tmpdir), 'a', 'b', 'c', 'd')
|
||||||
|
filepath = os.path.join(filedir, 'file')
|
||||||
|
metadir = os.path.join(str(tmpdir), spack.store.layout.metadata_dir)
|
||||||
|
|
||||||
|
fs.mkdirp(filedir)
|
||||||
|
fs.mkdirp(metadir)
|
||||||
|
|
||||||
|
with open(filepath, 'w') as f:
|
||||||
|
f.write("I'm a file")
|
||||||
|
|
||||||
|
data = spack.verify.create_manifest_entry(filepath)
|
||||||
|
|
||||||
|
manifest_file = os.path.join(metadir,
|
||||||
|
spack.store.layout.manifest_file_name)
|
||||||
|
|
||||||
|
with open(manifest_file, 'w') as f:
|
||||||
|
sjson.dump({filepath: data}, f)
|
||||||
|
|
||||||
|
results = verify('-f', filepath, fail_on_error=False)
|
||||||
|
print(results)
|
||||||
|
assert not results
|
||||||
|
|
||||||
|
os.utime(filepath, (0, 0))
|
||||||
|
with open(filepath, 'w') as f:
|
||||||
|
f.write("I changed.")
|
||||||
|
|
||||||
|
results = verify('-f', filepath, fail_on_error=False)
|
||||||
|
|
||||||
|
expected = ['hash']
|
||||||
|
mtime = os.stat(filepath).st_mtime
|
||||||
|
if mtime != data['time']:
|
||||||
|
expected.append('mtime')
|
||||||
|
|
||||||
|
assert results
|
||||||
|
assert filepath in results
|
||||||
|
assert all(x in results for x in expected)
|
||||||
|
|
||||||
|
results = verify('-fj', filepath, fail_on_error=False)
|
||||||
|
res = sjson.load(results)
|
||||||
|
assert len(res) == 1
|
||||||
|
errors = res.pop(filepath)
|
||||||
|
assert sorted(errors) == sorted(expected)
|
||||||
|
|
||||||
|
|
||||||
|
def test_single_spec_verify_cmd(tmpdir, mock_packages, mock_archive,
|
||||||
|
mock_fetch, config, install_mockery):
|
||||||
|
# Test the verify command interface to verify a single spec
|
||||||
|
install('libelf')
|
||||||
|
s = spack.spec.Spec('libelf').concretized()
|
||||||
|
prefix = s.prefix
|
||||||
|
hash = s.dag_hash()
|
||||||
|
|
||||||
|
results = verify('/%s' % hash, fail_on_error=False)
|
||||||
|
assert not results
|
||||||
|
|
||||||
|
new_file = os.path.join(prefix, 'new_file_for_verify_test')
|
||||||
|
with open(new_file, 'w') as f:
|
||||||
|
f.write('New file')
|
||||||
|
|
||||||
|
results = verify('/%s' % hash, fail_on_error=False)
|
||||||
|
assert new_file in results
|
||||||
|
assert 'added' in results
|
||||||
|
|
||||||
|
results = verify('-j', '/%s' % hash, fail_on_error=False)
|
||||||
|
res = sjson.load(results)
|
||||||
|
assert len(res) == 1
|
||||||
|
assert res[new_file] == ['added']
|
232
lib/spack/spack/test/verification.py
Normal file
232
lib/spack/spack/test/verification.py
Normal file
@ -0,0 +1,232 @@
|
|||||||
|
# Copyright 2013-2019 Lawrence Livermore National Security, LLC and other
|
||||||
|
# Spack Project Developers. See the top-level COPYRIGHT file for details.
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: (Apache-2.0 OR MIT)
|
||||||
|
|
||||||
|
"""Tests for the `spack.verify` module"""
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
import llnl.util.filesystem as fs
|
||||||
|
|
||||||
|
import spack.util.spack_json as sjson
|
||||||
|
import spack.verify
|
||||||
|
import spack.spec
|
||||||
|
import spack.store
|
||||||
|
|
||||||
|
|
||||||
|
def test_link_manifest_entry(tmpdir):
|
||||||
|
# Test that symlinks are properly checked against the manifest.
|
||||||
|
# Test that the appropriate errors are generated when the check fails.
|
||||||
|
file = str(tmpdir.join('file'))
|
||||||
|
open(file, 'a').close()
|
||||||
|
link = str(tmpdir.join('link'))
|
||||||
|
os.symlink(file, link)
|
||||||
|
|
||||||
|
data = spack.verify.create_manifest_entry(link)
|
||||||
|
assert data['type'] == 'link'
|
||||||
|
assert data['dest'] == file
|
||||||
|
assert all(x in data for x in ('mode', 'owner', 'group'))
|
||||||
|
|
||||||
|
results = spack.verify.check_entry(link, data)
|
||||||
|
assert not results.has_errors()
|
||||||
|
|
||||||
|
data['type'] = 'garbage'
|
||||||
|
|
||||||
|
results = spack.verify.check_entry(link, data)
|
||||||
|
assert results.has_errors()
|
||||||
|
assert link in results.errors
|
||||||
|
assert results.errors[link] == ['type']
|
||||||
|
|
||||||
|
data['type'] = 'link'
|
||||||
|
|
||||||
|
file2 = str(tmpdir.join('file2'))
|
||||||
|
open(file2, 'a').close()
|
||||||
|
os.remove(link)
|
||||||
|
os.symlink(file2, link)
|
||||||
|
|
||||||
|
results = spack.verify.check_entry(link, data)
|
||||||
|
assert results.has_errors()
|
||||||
|
assert link in results.errors
|
||||||
|
assert results.errors[link] == ['link']
|
||||||
|
|
||||||
|
|
||||||
|
def test_dir_manifest_entry(tmpdir):
|
||||||
|
# Test that directories are properly checked against the manifest.
|
||||||
|
# Test that the appropriate errors are generated when the check fails.
|
||||||
|
dirent = str(tmpdir.join('dir'))
|
||||||
|
fs.mkdirp(dirent)
|
||||||
|
|
||||||
|
data = spack.verify.create_manifest_entry(dirent)
|
||||||
|
assert data['type'] == 'dir'
|
||||||
|
assert all(x in data for x in ('mode', 'owner', 'group'))
|
||||||
|
|
||||||
|
results = spack.verify.check_entry(dirent, data)
|
||||||
|
assert not results.has_errors()
|
||||||
|
|
||||||
|
data['type'] = 'garbage'
|
||||||
|
|
||||||
|
results = spack.verify.check_entry(dirent, data)
|
||||||
|
assert results.has_errors()
|
||||||
|
assert dirent in results.errors
|
||||||
|
assert results.errors[dirent] == ['type']
|
||||||
|
|
||||||
|
|
||||||
|
def test_file_manifest_entry(tmpdir):
|
||||||
|
# Test that files are properly checked against the manifest.
|
||||||
|
# Test that the appropriate errors are generated when the check fails.
|
||||||
|
orig_str = 'This is a file'
|
||||||
|
new_str = 'The file has changed'
|
||||||
|
|
||||||
|
file = str(tmpdir.join('dir'))
|
||||||
|
with open(file, 'w') as f:
|
||||||
|
f.write(orig_str)
|
||||||
|
|
||||||
|
data = spack.verify.create_manifest_entry(file)
|
||||||
|
assert data['type'] == 'file'
|
||||||
|
assert data['size'] == len(orig_str)
|
||||||
|
assert all(x in data for x in ('mode', 'owner', 'group'))
|
||||||
|
|
||||||
|
results = spack.verify.check_entry(file, data)
|
||||||
|
assert not results.has_errors()
|
||||||
|
|
||||||
|
data['type'] = 'garbage'
|
||||||
|
|
||||||
|
results = spack.verify.check_entry(file, data)
|
||||||
|
assert results.has_errors()
|
||||||
|
assert file in results.errors
|
||||||
|
assert results.errors[file] == ['type']
|
||||||
|
|
||||||
|
data['type'] = 'file'
|
||||||
|
|
||||||
|
with open(file, 'w') as f:
|
||||||
|
f.write(new_str)
|
||||||
|
|
||||||
|
results = spack.verify.check_entry(file, data)
|
||||||
|
|
||||||
|
expected = ['size', 'hash']
|
||||||
|
mtime = os.stat(file).st_mtime
|
||||||
|
if mtime != data['time']:
|
||||||
|
expected.append('mtime')
|
||||||
|
|
||||||
|
assert results.has_errors()
|
||||||
|
assert file in results.errors
|
||||||
|
assert sorted(results.errors[file]) == sorted(expected)
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_chmod_manifest_entry(tmpdir):
|
||||||
|
# Check that the verification properly identifies errors for files whose
|
||||||
|
# permissions have been modified.
|
||||||
|
file = str(tmpdir.join('dir'))
|
||||||
|
with open(file, 'w') as f:
|
||||||
|
f.write('This is a file')
|
||||||
|
|
||||||
|
data = spack.verify.create_manifest_entry(file)
|
||||||
|
|
||||||
|
os.chmod(file, data['mode'] - 1)
|
||||||
|
|
||||||
|
results = spack.verify.check_entry(file, data)
|
||||||
|
assert results.has_errors()
|
||||||
|
assert file in results.errors
|
||||||
|
assert results.errors[file] == ['mode']
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_prefix_manifest(tmpdir):
|
||||||
|
# Test the verification of an entire prefix and its contents
|
||||||
|
prefix_path = tmpdir.join('prefix')
|
||||||
|
prefix = str(prefix_path)
|
||||||
|
|
||||||
|
spec = spack.spec.Spec('libelf')
|
||||||
|
spec._mark_concrete()
|
||||||
|
spec.prefix = prefix
|
||||||
|
|
||||||
|
results = spack.verify.check_spec_manifest(spec)
|
||||||
|
assert results.has_errors()
|
||||||
|
assert prefix in results.errors
|
||||||
|
assert results.errors[prefix] == ['manifest missing']
|
||||||
|
|
||||||
|
metadata_dir = str(prefix_path.join('.spack'))
|
||||||
|
bin_dir = str(prefix_path.join('bin'))
|
||||||
|
other_dir = str(prefix_path.join('other'))
|
||||||
|
|
||||||
|
for d in (metadata_dir, bin_dir, other_dir):
|
||||||
|
fs.mkdirp(d)
|
||||||
|
|
||||||
|
file = os.path.join(other_dir, 'file')
|
||||||
|
with open(file, 'w') as f:
|
||||||
|
f.write("I'm a little file short and stout")
|
||||||
|
|
||||||
|
link = os.path.join(bin_dir, 'run')
|
||||||
|
os.symlink(file, link)
|
||||||
|
|
||||||
|
spack.verify.write_manifest(spec)
|
||||||
|
results = spack.verify.check_spec_manifest(spec)
|
||||||
|
assert not results.has_errors()
|
||||||
|
|
||||||
|
os.remove(link)
|
||||||
|
malware = os.path.join(metadata_dir, 'hiddenmalware')
|
||||||
|
with open(malware, 'w') as f:
|
||||||
|
f.write("Foul evil deeds")
|
||||||
|
|
||||||
|
results = spack.verify.check_spec_manifest(spec)
|
||||||
|
assert results.has_errors()
|
||||||
|
assert all(x in results.errors for x in (malware, link))
|
||||||
|
assert len(results.errors) == 2
|
||||||
|
|
||||||
|
assert results.errors[link] == ['deleted']
|
||||||
|
assert results.errors[malware] == ['added']
|
||||||
|
|
||||||
|
manifest_file = os.path.join(spec.prefix,
|
||||||
|
spack.store.layout.metadata_dir,
|
||||||
|
spack.store.layout.manifest_file_name)
|
||||||
|
with open(manifest_file, 'w') as f:
|
||||||
|
f.write("{This) string is not proper json")
|
||||||
|
|
||||||
|
results = spack.verify.check_spec_manifest(spec)
|
||||||
|
assert results.has_errors()
|
||||||
|
assert results.errors[spec.prefix] == ['manifest corrupted']
|
||||||
|
|
||||||
|
|
||||||
|
def test_single_file_verification(tmpdir):
|
||||||
|
# Test the API to verify a single file, including finding the package
|
||||||
|
# to which it belongs
|
||||||
|
filedir = os.path.join(str(tmpdir), 'a', 'b', 'c', 'd')
|
||||||
|
filepath = os.path.join(filedir, 'file')
|
||||||
|
metadir = os.path.join(str(tmpdir), spack.store.layout.metadata_dir)
|
||||||
|
|
||||||
|
fs.mkdirp(filedir)
|
||||||
|
fs.mkdirp(metadir)
|
||||||
|
|
||||||
|
with open(filepath, 'w') as f:
|
||||||
|
f.write("I'm a file")
|
||||||
|
|
||||||
|
data = spack.verify.create_manifest_entry(filepath)
|
||||||
|
|
||||||
|
manifest_file = os.path.join(metadir,
|
||||||
|
spack.store.layout.manifest_file_name)
|
||||||
|
|
||||||
|
with open(manifest_file, 'w') as f:
|
||||||
|
sjson.dump({filepath: data}, f)
|
||||||
|
|
||||||
|
results = spack.verify.check_file_manifest(filepath)
|
||||||
|
assert not results.has_errors()
|
||||||
|
|
||||||
|
os.utime(filepath, (0, 0))
|
||||||
|
with open(filepath, 'w') as f:
|
||||||
|
f.write("I changed.")
|
||||||
|
|
||||||
|
results = spack.verify.check_file_manifest(filepath)
|
||||||
|
|
||||||
|
expected = ['hash']
|
||||||
|
mtime = os.stat(filepath).st_mtime
|
||||||
|
if mtime != data['time']:
|
||||||
|
expected.append('mtime')
|
||||||
|
|
||||||
|
assert results.has_errors()
|
||||||
|
assert filepath in results.errors
|
||||||
|
assert sorted(results.errors[filepath]) == sorted(expected)
|
||||||
|
|
||||||
|
shutil.rmtree(metadir)
|
||||||
|
results = spack.verify.check_file_manifest(filepath)
|
||||||
|
assert results.has_errors()
|
||||||
|
assert results.errors[filepath] == ['not owned by any package']
|
244
lib/spack/spack/verify.py
Normal file
244
lib/spack/spack/verify.py
Normal file
@ -0,0 +1,244 @@
|
|||||||
|
# Copyright 2013-2019 Lawrence Livermore National Security, LLC and other
|
||||||
|
# Spack Project Developers. See the top-level COPYRIGHT file for details.
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: (Apache-2.0 OR MIT)
|
||||||
|
import os
|
||||||
|
import hashlib
|
||||||
|
import base64
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import llnl.util.tty as tty
|
||||||
|
|
||||||
|
import spack.util.spack_json as sjson
|
||||||
|
import spack.util.file_permissions as fp
|
||||||
|
import spack.store
|
||||||
|
import spack.filesystem_view
|
||||||
|
|
||||||
|
|
||||||
|
def compute_hash(path):
|
||||||
|
with open(path, 'rb') as f:
|
||||||
|
sha1 = hashlib.sha1(f.read()).digest()
|
||||||
|
b32 = base64.b32encode(sha1)
|
||||||
|
|
||||||
|
if sys.version_info[0] >= 3:
|
||||||
|
b32 = b32.decode()
|
||||||
|
|
||||||
|
return b32
|
||||||
|
|
||||||
|
|
||||||
|
def create_manifest_entry(path):
|
||||||
|
data = {}
|
||||||
|
stat = os.stat(path)
|
||||||
|
|
||||||
|
data['mode'] = stat.st_mode
|
||||||
|
data['owner'] = stat.st_uid
|
||||||
|
data['group'] = stat.st_gid
|
||||||
|
|
||||||
|
if os.path.islink(path):
|
||||||
|
data['type'] = 'link'
|
||||||
|
data['dest'] = os.readlink(path)
|
||||||
|
|
||||||
|
elif os.path.isdir(path):
|
||||||
|
data['type'] = 'dir'
|
||||||
|
|
||||||
|
else:
|
||||||
|
data['type'] = 'file'
|
||||||
|
data['hash'] = compute_hash(path)
|
||||||
|
data['time'] = stat.st_mtime
|
||||||
|
data['size'] = stat.st_size
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def write_manifest(spec):
|
||||||
|
manifest_file = os.path.join(spec.prefix,
|
||||||
|
spack.store.layout.metadata_dir,
|
||||||
|
spack.store.layout.manifest_file_name)
|
||||||
|
|
||||||
|
if not os.path.exists(manifest_file):
|
||||||
|
tty.debug("Writing manifest file: No manifest from binary")
|
||||||
|
|
||||||
|
manifest = {}
|
||||||
|
for root, dirs, files in os.walk(spec.prefix):
|
||||||
|
for entry in list(dirs + files):
|
||||||
|
path = os.path.join(root, entry)
|
||||||
|
manifest[path] = create_manifest_entry(path)
|
||||||
|
manifest[spec.prefix] = create_manifest_entry(spec.prefix)
|
||||||
|
|
||||||
|
with open(manifest_file, 'w') as f:
|
||||||
|
sjson.dump(manifest, f)
|
||||||
|
|
||||||
|
fp.set_permissions_by_spec(manifest_file, spec)
|
||||||
|
|
||||||
|
|
||||||
|
def check_entry(path, data):
|
||||||
|
res = VerificationResults()
|
||||||
|
|
||||||
|
if not data:
|
||||||
|
res.add_error(path, 'added')
|
||||||
|
return res
|
||||||
|
|
||||||
|
stat = os.stat(path)
|
||||||
|
|
||||||
|
# Check for all entries
|
||||||
|
if stat.st_mode != data['mode']:
|
||||||
|
res.add_error(path, 'mode')
|
||||||
|
if stat.st_uid != data['owner']:
|
||||||
|
res.add_error(path, 'owner')
|
||||||
|
if stat.st_gid != data['group']:
|
||||||
|
res.add_error(path, 'group')
|
||||||
|
|
||||||
|
# Check for symlink targets and listed as symlink
|
||||||
|
if os.path.islink(path):
|
||||||
|
if data['type'] != 'link':
|
||||||
|
res.add_error(path, 'type')
|
||||||
|
if os.readlink(path) != data.get('dest', ''):
|
||||||
|
res.add_error(path, 'link')
|
||||||
|
|
||||||
|
# Check directories are listed as directory
|
||||||
|
elif os.path.isdir(path):
|
||||||
|
if data['type'] != 'dir':
|
||||||
|
res.add_error(path, 'type')
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Check file contents against hash and listed as file
|
||||||
|
# Check mtime and size as well
|
||||||
|
if stat.st_size != data['size']:
|
||||||
|
res.add_error(path, 'size')
|
||||||
|
if stat.st_mtime != data['time']:
|
||||||
|
res.add_error(path, 'mtime')
|
||||||
|
if data['type'] != 'file':
|
||||||
|
res.add_error(path, 'type')
|
||||||
|
if compute_hash(path) != data.get('hash', ''):
|
||||||
|
res.add_error(path, 'hash')
|
||||||
|
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
def check_file_manifest(file):
|
||||||
|
dirname = os.path.dirname(file)
|
||||||
|
|
||||||
|
results = VerificationResults()
|
||||||
|
while spack.store.layout.metadata_dir not in os.listdir(dirname):
|
||||||
|
if dirname == os.path.sep:
|
||||||
|
results.add_error(file, 'not owned by any package')
|
||||||
|
return results
|
||||||
|
dirname = os.path.dirname(dirname)
|
||||||
|
|
||||||
|
manifest_file = os.path.join(dirname,
|
||||||
|
spack.store.layout.metadata_dir,
|
||||||
|
spack.store.layout.manifest_file_name)
|
||||||
|
|
||||||
|
if not os.path.exists(manifest_file):
|
||||||
|
results.add_error(file, "manifest missing")
|
||||||
|
return results
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(manifest_file, 'r') as f:
|
||||||
|
manifest = sjson.load(f)
|
||||||
|
except Exception:
|
||||||
|
results.add_error(file, "manifest corrupted")
|
||||||
|
return results
|
||||||
|
|
||||||
|
if file in manifest:
|
||||||
|
results += check_entry(file, manifest[file])
|
||||||
|
else:
|
||||||
|
results.add_error(file, 'not owned by any package')
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def check_spec_manifest(spec):
|
||||||
|
prefix = spec.prefix
|
||||||
|
|
||||||
|
results = VerificationResults()
|
||||||
|
manifest_file = os.path.join(prefix,
|
||||||
|
spack.store.layout.metadata_dir,
|
||||||
|
spack.store.layout.manifest_file_name)
|
||||||
|
|
||||||
|
if not os.path.exists(manifest_file):
|
||||||
|
results.add_error(prefix, "manifest missing")
|
||||||
|
return results
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(manifest_file, 'r') as f:
|
||||||
|
manifest = sjson.load(f)
|
||||||
|
except Exception:
|
||||||
|
results.add_error(prefix, "manifest corrupted")
|
||||||
|
return results
|
||||||
|
|
||||||
|
# Get extensions active in spec
|
||||||
|
view = spack.filesystem_view.YamlFilesystemView(prefix,
|
||||||
|
spack.store.layout)
|
||||||
|
active_exts = view.extensions_layout.extension_map(spec).values()
|
||||||
|
ext_file = ''
|
||||||
|
if active_exts:
|
||||||
|
# No point checking contents of this file as it is the only source of
|
||||||
|
# truth for that information.
|
||||||
|
ext_file = view.extensions_layout.extension_file_path(spec)
|
||||||
|
|
||||||
|
def is_extension_artifact(p):
|
||||||
|
if os.path.islink(p):
|
||||||
|
if any(os.readlink(p).startswith(e.prefix) for e in active_exts):
|
||||||
|
# This file is linked in by an extension. Belongs to extension
|
||||||
|
return True
|
||||||
|
elif os.path.isdir(p) and p not in manifest:
|
||||||
|
if all(is_extension_artifact(os.path.join(p, f))
|
||||||
|
for f in os.listdir(p)):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
for root, dirs, files in os.walk(prefix):
|
||||||
|
for entry in list(dirs + files):
|
||||||
|
path = os.path.join(root, entry)
|
||||||
|
|
||||||
|
# Do not check links from prefix to active extension
|
||||||
|
# TODO: make this stricter for non-linux systems that use symlink
|
||||||
|
# permissions
|
||||||
|
# Do not check directories that only exist for extensions
|
||||||
|
if is_extension_artifact(path):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Do not check manifest file. Can't store your own hash
|
||||||
|
# Nothing to check for ext_file
|
||||||
|
if path == manifest_file or path == ext_file:
|
||||||
|
continue
|
||||||
|
|
||||||
|
data = manifest.pop(path, {})
|
||||||
|
results += check_entry(path, data)
|
||||||
|
|
||||||
|
results += check_entry(prefix, manifest.pop(prefix, {}))
|
||||||
|
|
||||||
|
for path in manifest:
|
||||||
|
results.add_error(path, 'deleted')
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
class VerificationResults(object):
|
||||||
|
def __init__(self):
|
||||||
|
self.errors = {}
|
||||||
|
|
||||||
|
def add_error(self, path, field):
|
||||||
|
self.errors[path] = self.errors.get(path, []) + [field]
|
||||||
|
|
||||||
|
def __add__(self, vr):
|
||||||
|
for path, fields in vr.errors.items():
|
||||||
|
self.errors[path] = self.errors.get(path, []) + fields
|
||||||
|
return self
|
||||||
|
|
||||||
|
def has_errors(self):
|
||||||
|
return bool(self.errors)
|
||||||
|
|
||||||
|
def json_string(self):
|
||||||
|
return sjson.dump(self.errors)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
res = ''
|
||||||
|
for path, fields in self.errors.items():
|
||||||
|
res += '%s verification failed with error(s):\n' % path
|
||||||
|
for error in fields:
|
||||||
|
res += ' %s\n' % error
|
||||||
|
|
||||||
|
if not res:
|
||||||
|
res += 'No Errors'
|
||||||
|
return res
|
Loading…
Reference in New Issue
Block a user