Merge pull request 'Add the personal-data catalog and the check that keeps it true' (#83) from feature/privacy-catalog into main
This commit was merged in pull request #83.
This commit is contained in:
@@ -40,6 +40,12 @@ jobs:
|
|||||||
# nothing. CI never sees the difference; a release does.
|
# nothing. CI never sees the difference; a release does.
|
||||||
- if: always()
|
- if: always()
|
||||||
run: python3 tools/fork/context-check.py
|
run: python3 tools/fork/context-check.py
|
||||||
|
# The personal-data catalog must classify every object and field the
|
||||||
|
# schema has, and name nothing that is gone.
|
||||||
|
- if: always()
|
||||||
|
run: python3 tools/fork/privacy-check.py
|
||||||
|
- if: always()
|
||||||
|
run: python3 -m unittest discover -s tools/fork/tests
|
||||||
|
|
||||||
build:
|
build:
|
||||||
# Either runner (host1 or host2): the build needs no docker socket.
|
# Either runner (host1 or host2): the build needs no docker socket.
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -68,6 +68,25 @@ tools/fork/notice-check.py --fix # add it where it's missing
|
|||||||
Run `--fix` after resolving an upstream merge: a conflict resolved by taking
|
Run `--fix` after resolving an upstream merge: a conflict resolved by taking
|
||||||
upstream's side can drop a notice the file had.
|
upstream's side can drop a notice the file had.
|
||||||
|
|
||||||
|
## privacy-check.py
|
||||||
|
|
||||||
|
Fails when the personal-data catalog (`resources/privacy/catalog.toml`) and
|
||||||
|
the code disagree: an object in the schema or one of inbuxa's own JMAP
|
||||||
|
objects with no entry, a property the schema types as an address, an IP or
|
||||||
|
a secret left to its object's default, or an entry naming an object,
|
||||||
|
property, setting or code path that no longer exists. CI runs it beside the
|
||||||
|
name check, with its tests (`python3 -m unittest discover -s tools/fork/tests`).
|
||||||
|
See `docs/spec/features/personal-data-catalog.md`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tools/fork/privacy-check.py # exit 1 on any finding
|
||||||
|
tools/fork/privacy-check.py --unlisted # starting entries for what's missing
|
||||||
|
```
|
||||||
|
|
||||||
|
After an upstream import, the strip report lists what is new and unclassified
|
||||||
|
under "Unclassified in the privacy catalog". `--unlisted` types each from the
|
||||||
|
schema alone; read the field's description before trusting it.
|
||||||
|
|
||||||
## record-compat.py
|
## record-compat.py
|
||||||
|
|
||||||
Records what the `*_compat` tests compare against, from the Enterprise
|
Records what the `*_compat` tests compare against, from the Enterprise
|
||||||
|
|||||||
Executable
+228
@@ -0,0 +1,228 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
# SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
"""
|
||||||
|
Fail when the personal-data catalog and the code disagree.
|
||||||
|
|
||||||
|
tools/fork/privacy-check.py # check; exit 1 on any finding
|
||||||
|
tools/fork/privacy-check.py --unlisted # print catalog entries for what's missing
|
||||||
|
|
||||||
|
The catalog (`resources/privacy/catalog.toml`, spec
|
||||||
|
`docs/spec/features/personal-data-catalog.md`) says, for every object and
|
||||||
|
source, what personal data it can hold. An upstream import can bring objects
|
||||||
|
and fields nobody has classified, and a refactor can leave the catalog naming
|
||||||
|
things that are gone; either way the catalog stops being true without anyone
|
||||||
|
noticing, so this runs in CI on every push and pull request. It fails when:
|
||||||
|
|
||||||
|
1. an object in the schema's `fields`, or one of inbuxa's own JMAP objects,
|
||||||
|
has no catalog entry;
|
||||||
|
2. a property the schema types as an email address, an IP address or
|
||||||
|
network, or a secret is covered only by its object's `default` -- it
|
||||||
|
must be listed, so a new personal field can't hide behind a default;
|
||||||
|
3. an entry names an object, property, setting or code path that doesn't
|
||||||
|
exist (stale);
|
||||||
|
4. an entry uses a word outside the catalog's own vocabulary.
|
||||||
|
|
||||||
|
When it fails on a new object or field, classify it: `--unlisted` prints a
|
||||||
|
starting entry for each, typed from the schema alone. Read the property's
|
||||||
|
description before trusting it.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import gzip
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import tomllib
|
||||||
|
|
||||||
|
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
SCHEMA = 'resources/schema/schema.json.gz'
|
||||||
|
CATALOG = 'resources/privacy/catalog.toml'
|
||||||
|
OBJECTS_DIR = 'crates/jmap-proto/src/object'
|
||||||
|
METHODS = 'crates/jmap-proto/src/request/method.rs'
|
||||||
|
|
||||||
|
SENSITIVE_FORMATS = {
|
||||||
|
'emailAddress': 'identifier',
|
||||||
|
'ipAddress': 'network',
|
||||||
|
'ipNetwork': 'network',
|
||||||
|
'secret': 'credential',
|
||||||
|
'secretText': 'credential',
|
||||||
|
}
|
||||||
|
INBUXA_OBJECT = re.compile(r'"(inbuxa:[A-Z][A-Za-z]*)"')
|
||||||
|
PROPERTY_NAME = re.compile(r'=> "([a-z][A-Za-z0-9]*)"')
|
||||||
|
|
||||||
|
|
||||||
|
def sensitive(type_):
|
||||||
|
"""The category a property's schema type alone implies, or None."""
|
||||||
|
fmt = type_.get('format') or (type_.get('class') or {}).get('format')
|
||||||
|
if fmt in SENSITIVE_FORMATS:
|
||||||
|
return SENSITIVE_FORMATS[fmt]
|
||||||
|
name = type_.get('objectName') or (type_.get('class') or {}).get('objectName') or ''
|
||||||
|
if name.startswith(('x:SecretKey', 'x:SecretText')) or name == 'x:HttpAuth':
|
||||||
|
return 'credential'
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def load_schema(root):
|
||||||
|
with gzip.open(os.path.join(root, SCHEMA)) as f:
|
||||||
|
return json.load(f)['fields']
|
||||||
|
|
||||||
|
|
||||||
|
def load_inbuxa_objects(root):
|
||||||
|
"""inbuxa's own objects, from the method names jmap-proto parses."""
|
||||||
|
with open(os.path.join(root, METHODS), encoding='utf-8') as f:
|
||||||
|
return set(INBUXA_OBJECT.findall(f.read()))
|
||||||
|
|
||||||
|
|
||||||
|
def inbuxa_properties(root, file_name):
|
||||||
|
"""The property names a jmap-proto object file maps, or None if it's gone."""
|
||||||
|
path = os.path.join(root, OBJECTS_DIR, file_name)
|
||||||
|
if not os.path.isfile(path):
|
||||||
|
return None
|
||||||
|
with open(path, encoding='utf-8') as f:
|
||||||
|
return set(PROPERTY_NAME.findall(f.read()))
|
||||||
|
|
||||||
|
|
||||||
|
def findings(root, catalog):
|
||||||
|
"""Everything wrong with `catalog` against the tree at `root`."""
|
||||||
|
fields = load_schema(root)
|
||||||
|
inbuxa = load_inbuxa_objects(root)
|
||||||
|
vocab = catalog.get('vocabulary', {})
|
||||||
|
objects = catalog.get('object', {})
|
||||||
|
sources = catalog.get('source', {})
|
||||||
|
out = []
|
||||||
|
inbuxa_props = {}
|
||||||
|
|
||||||
|
def vocabulary(where, key, values):
|
||||||
|
allowed = set(vocab.get(key, []))
|
||||||
|
for value in values if isinstance(values, list) else [values]:
|
||||||
|
if value not in allowed:
|
||||||
|
out.append(f'{where}: "{value}" is not in vocabulary.{key}')
|
||||||
|
|
||||||
|
def setting_exists(where, setting):
|
||||||
|
obj, _, prop = setting.partition('.')
|
||||||
|
if obj in fields:
|
||||||
|
if prop not in fields[obj].get('properties', {}):
|
||||||
|
out.append(f'{where}: setting {setting} names no property of {obj}')
|
||||||
|
elif obj in inbuxa:
|
||||||
|
props = inbuxa_props.get(obj)
|
||||||
|
if props is not None and prop not in props:
|
||||||
|
out.append(f'{where}: setting {setting} names no property of {obj}')
|
||||||
|
else:
|
||||||
|
out.append(f'{where}: setting {setting} names no object')
|
||||||
|
|
||||||
|
def common(where, entry):
|
||||||
|
for key in ('whose', 'where', 'scope'):
|
||||||
|
if key in entry:
|
||||||
|
vocabulary(where, key, entry[key])
|
||||||
|
retention = entry.get('retention')
|
||||||
|
if isinstance(retention, dict):
|
||||||
|
setting_exists(where, retention.get('setting', ''))
|
||||||
|
elif retention is not None:
|
||||||
|
vocabulary(where, 'retention', retention)
|
||||||
|
for key in ('enabled_by', 'captures'):
|
||||||
|
for setting in entry.get(key, []):
|
||||||
|
setting_exists(where, setting)
|
||||||
|
|
||||||
|
# inbuxa objects' properties first, so settings can name them
|
||||||
|
for name, entry in objects.items():
|
||||||
|
if name.startswith('inbuxa:'):
|
||||||
|
inbuxa_props[name] = inbuxa_properties(root, entry.get('file', ''))
|
||||||
|
|
||||||
|
# 1: nothing unlisted
|
||||||
|
for name in sorted(fields):
|
||||||
|
if name not in objects:
|
||||||
|
out.append(f'{name}: schema object has no catalog entry')
|
||||||
|
for name in sorted(inbuxa):
|
||||||
|
if name not in objects:
|
||||||
|
out.append(f'{name}: inbuxa object has no catalog entry')
|
||||||
|
|
||||||
|
for name, entry in sorted(objects.items()):
|
||||||
|
props = entry.get('properties', {})
|
||||||
|
if name.startswith('inbuxa:'):
|
||||||
|
known = inbuxa_props.get(name)
|
||||||
|
if name not in inbuxa:
|
||||||
|
out.append(f'{name}: no such inbuxa object (stale)')
|
||||||
|
if known is None:
|
||||||
|
out.append(f'{name}: file "{entry.get("file", "")}" not found in {OBJECTS_DIR}')
|
||||||
|
known = set()
|
||||||
|
elif name in fields:
|
||||||
|
known = set(fields[name].get('properties', {}))
|
||||||
|
else:
|
||||||
|
out.append(f'{name}: no such schema object (stale)')
|
||||||
|
continue
|
||||||
|
if entry.get('default') != 'none':
|
||||||
|
out.append(f'{name}: default must be "none"')
|
||||||
|
for prop, categories in props.items():
|
||||||
|
if prop not in known:
|
||||||
|
out.append(f'{name}.{prop}: no such property (stale)')
|
||||||
|
vocabulary(f'{name}.{prop}', 'categories', categories)
|
||||||
|
common(name, entry)
|
||||||
|
# 2: typed-sensitive properties are listed
|
||||||
|
if name in fields:
|
||||||
|
for prop, spec in fields[name].get('properties', {}).items():
|
||||||
|
if prop not in props and sensitive(spec['type']):
|
||||||
|
out.append(f'{name}.{prop}: typed as {sensitive(spec["type"])} but not listed')
|
||||||
|
|
||||||
|
for name, entry in sorted(sources.items()):
|
||||||
|
where = f'source "{name}"'
|
||||||
|
vocabulary(where, 'categories', entry.get('categories', []))
|
||||||
|
common(where, entry)
|
||||||
|
if 'leaves_host' not in entry:
|
||||||
|
out.append(f'{where}: leaves_host is missing')
|
||||||
|
for path in entry.get('written_by', []):
|
||||||
|
if not os.path.exists(os.path.join(root, path)):
|
||||||
|
out.append(f'{where}: written_by {path} doesn\'t exist (stale)')
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def unlisted(root, catalog):
|
||||||
|
"""Starting catalog entries for unlisted objects and properties."""
|
||||||
|
fields = load_schema(root)
|
||||||
|
objects = catalog.get('object', {})
|
||||||
|
lines = []
|
||||||
|
for name in sorted(fields):
|
||||||
|
entry = objects.get(name)
|
||||||
|
listed = (entry or {}).get('properties', {})
|
||||||
|
missing = {
|
||||||
|
p: sensitive(spec['type'])
|
||||||
|
for p, spec in fields[name].get('properties', {}).items()
|
||||||
|
if p not in listed and sensitive(spec['type'])
|
||||||
|
}
|
||||||
|
if entry is None or missing:
|
||||||
|
lines.append(f'[object.{json.dumps(name)}]' if entry is None else f'# add to {name}:')
|
||||||
|
if entry is None:
|
||||||
|
lines.append('default = "none"')
|
||||||
|
if missing:
|
||||||
|
if entry is None:
|
||||||
|
lines.append(f'[object.{json.dumps(name)}.properties]')
|
||||||
|
for prop, category in sorted(missing.items()):
|
||||||
|
lines.append(f'{prop} = ["{category}"]')
|
||||||
|
lines.append('')
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None):
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__.split('\n')[1])
|
||||||
|
parser.add_argument('--unlisted', action='store_true', help='print entries for what is missing')
|
||||||
|
parser.add_argument('--root', default=ROOT, help=argparse.SUPPRESS)
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
with open(os.path.join(args.root, CATALOG), 'rb') as f:
|
||||||
|
catalog = tomllib.load(f)
|
||||||
|
if args.unlisted:
|
||||||
|
print('\n'.join(unlisted(args.root, catalog)))
|
||||||
|
return 0
|
||||||
|
found = findings(args.root, catalog)
|
||||||
|
if found:
|
||||||
|
print(f'privacy check: {len(found)} finding(s) in {CATALOG}:')
|
||||||
|
for line in found:
|
||||||
|
print(f' {line}')
|
||||||
|
print('Classify new objects and fields (--unlisted helps); remove what no longer exists.')
|
||||||
|
return 1
|
||||||
|
print(f'privacy check: clean ({len(catalog.get("object", {}))} objects, '
|
||||||
|
f'{len(catalog.get("source", {}))} sources).')
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
sys.exit(main())
|
||||||
+43
-1
@@ -373,6 +373,39 @@ def schema_flags(tree):
|
|||||||
return {'objects': objects, 'fields': fields}
|
return {'objects': objects, 'fields': fields}
|
||||||
|
|
||||||
|
|
||||||
|
def privacy_flags(tree):
|
||||||
|
"""Objects and properties new in this import that the personal-data
|
||||||
|
catalog doesn't classify (docs/spec/features/personal-data-catalog.md).
|
||||||
|
Informational, like the Enterprise flags: privacy-check.py is what fails
|
||||||
|
CI once the import is merged."""
|
||||||
|
import importlib.util
|
||||||
|
import tomllib
|
||||||
|
here = Path(__file__).resolve().parent
|
||||||
|
catalog_path = here.parents[1] / 'resources' / 'privacy' / 'catalog.toml'
|
||||||
|
current_path = here.parents[1] / 'resources' / 'schema' / 'schema.json.gz'
|
||||||
|
path = tree / 'resources' / 'schema' / 'schema.json.gz'
|
||||||
|
if not (path.is_file() and catalog_path.is_file() and current_path.is_file()):
|
||||||
|
return None
|
||||||
|
spec = importlib.util.spec_from_file_location('privacy_check', here / 'privacy-check.py')
|
||||||
|
check = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(check)
|
||||||
|
catalog = tomllib.loads(catalog_path.read_text(encoding='utf-8')).get('object', {})
|
||||||
|
current = json.loads(gzip.decompress(current_path.read_bytes())).get('fields', {})
|
||||||
|
upstream = json.loads(gzip.decompress(path.read_bytes())).get('fields', {})
|
||||||
|
objects = sorted(name for name in upstream if name not in catalog)
|
||||||
|
fields = []
|
||||||
|
for name, spec_ in upstream.items():
|
||||||
|
if name not in catalog:
|
||||||
|
continue
|
||||||
|
listed = catalog[name].get('properties', {})
|
||||||
|
known = current.get(name, {}).get('properties', {})
|
||||||
|
for prop, p in spec_.get('properties', {}).items():
|
||||||
|
if prop not in known and prop not in listed:
|
||||||
|
typed = check.sensitive(p.get('type', {}))
|
||||||
|
fields.append(f'{name}.{prop}' + (f' ({typed})' if typed else ''))
|
||||||
|
return {'objects': objects, 'fields': sorted(fields)}
|
||||||
|
|
||||||
|
|
||||||
def remaining_hooks(tree):
|
def remaining_hooks(tree):
|
||||||
gates, checks = {}, {}
|
gates, checks = {}, {}
|
||||||
for path in tree.rglob('*.rs'):
|
for path in tree.rglob('*.rs'):
|
||||||
@@ -456,6 +489,8 @@ def write_report(out_dir, report):
|
|||||||
]
|
]
|
||||||
if r['schema']:
|
if r['schema']:
|
||||||
md.append(f'- Upstream schema flags {len(r["schema"]["objects"])} objects and {len(r["schema"]["fields"])} fields as Enterprise')
|
md.append(f'- Upstream schema flags {len(r["schema"]["objects"])} objects and {len(r["schema"]["fields"])} fields as Enterprise')
|
||||||
|
if r.get('privacy'):
|
||||||
|
md.append(f'- Unclassified in the privacy catalog: {len(r["privacy"]["objects"])} objects and {len(r["privacy"]["fields"])} new fields')
|
||||||
md += ['', '## Removed files', ''] + [f'- `{f}`' for f in r['removed_files']]
|
md += ['', '## Removed files', ''] + [f'- `{f}`' for f in r['removed_files']]
|
||||||
md += ['', '## Removed snippets', ''] + [f'- `{f}`: {n}' for f, n in r['removed_snippets'].items()]
|
md += ['', '## Removed snippets', ''] + [f'- `{f}`: {n}' for f, n in r['removed_snippets'].items()]
|
||||||
md += ['', '## Dangling module declarations removed', ''] + [f'- `{d["file"]}:{d["line"]}`: `mod {d["module"]}` ({" / ".join(d["lines"])})' for d in r['dangling_mods']]
|
md += ['', '## Dangling module declarations removed', ''] + [f'- `{d["file"]}:{d["line"]}`: `mod {d["module"]}` ({" / ".join(d["lines"])})' for d in r['dangling_mods']]
|
||||||
@@ -463,6 +498,13 @@ def write_report(out_dir, report):
|
|||||||
if r['schema']:
|
if r['schema']:
|
||||||
md += ['', '## Flagged Enterprise in upstream\'s schema', '', '**Objects:** ' + ', '.join(f'`{o}`' for o in r['schema']['objects']),
|
md += ['', '## Flagged Enterprise in upstream\'s schema', '', '**Objects:** ' + ', '.join(f'`{o}`' for o in r['schema']['objects']),
|
||||||
'', '**Fields:** ' + ', '.join(f'`{f}`' for f in r['schema']['fields'])]
|
'', '**Fields:** ' + ', '.join(f'`{f}`' for f in r['schema']['fields'])]
|
||||||
|
if r.get('privacy'):
|
||||||
|
md += ['', '## Unclassified in the privacy catalog', '',
|
||||||
|
'New in this import and not in `resources/privacy/catalog.toml`. `tools/fork/privacy-check.py` '
|
||||||
|
'fails CI on these once merged; `--unlisted` prints a starting entry. A type in brackets is what '
|
||||||
|
'the schema alone says the field holds.', '',
|
||||||
|
'**Objects:** ' + (', '.join(f'`{o}`' for o in r['privacy']['objects']) or 'none'),
|
||||||
|
'', '**Fields:** ' + (', '.join(f'`{f}`' for f in r['privacy']['fields']) or 'none')]
|
||||||
md += ['', '## Third-party code', '',
|
md += ['', '## Third-party code', '',
|
||||||
'Comments in the stripped tree that name another copyright holder, another license, or a source the '
|
'Comments in the stripped tree that name another copyright holder, another license, or a source the '
|
||||||
'code came from. Files marked **new** aren\'t in THIRD-PARTY.md yet.', '']
|
'code came from. Files marked **new** aren\'t in THIRD-PARTY.md yet.', '']
|
||||||
@@ -537,7 +579,7 @@ def main():
|
|||||||
'removed_files': removed_files, 'removed_snippets': removed_snippets,
|
'removed_files': removed_files, 'removed_snippets': removed_snippets,
|
||||||
'cargo_edits': edits, 'dangling_mods': dangling, 'problems': problems,
|
'cargo_edits': edits, 'dangling_mods': dangling, 'problems': problems,
|
||||||
'feature_gates': gates, 'edition_checks': checks,
|
'feature_gates': gates, 'edition_checks': checks,
|
||||||
'schema': schema_flags(tree), 'third_party': others, 'third_party_unlisted': new_others,
|
'schema': schema_flags(tree), 'privacy': privacy_flags(tree), 'third_party': others, 'third_party_unlisted': new_others,
|
||||||
'renames': renames, 'build': build, 'ossify_log': log,
|
'renames': renames, 'build': build, 'ossify_log': log,
|
||||||
}
|
}
|
||||||
write_report(args.out, report)
|
write_report(args.out, report)
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
# SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
# SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
"""Tests for tools/fork/privacy-check.py: python3 -m unittest discover tools/fork/tests"""
|
||||||
|
import gzip
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import tomllib
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
spec = importlib.util.spec_from_file_location('privacy_check', os.path.join(HERE, '..', 'privacy-check.py'))
|
||||||
|
check = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(check)
|
||||||
|
|
||||||
|
VOCAB = """
|
||||||
|
[vocabulary]
|
||||||
|
categories = ["identifier", "contact", "network", "content", "metadata", "credential"]
|
||||||
|
whose = ["holder", "correspondent", "administrator"]
|
||||||
|
where = ["data-store", "blob-store", "search-store", "in-memory-store", "memory", "log-file", "external"]
|
||||||
|
scope = ["tenant", "server"]
|
||||||
|
retention = ["unbounded", "object-life", "receiver"]
|
||||||
|
"""
|
||||||
|
|
||||||
|
GOOD = VOCAB + """
|
||||||
|
[object."x:Widget"]
|
||||||
|
default = "none"
|
||||||
|
whose = ["holder"]
|
||||||
|
where = ["data-store"]
|
||||||
|
scope = "tenant"
|
||||||
|
retention = { setting = "x:Widget.keepFor" }
|
||||||
|
[object."x:Widget".properties]
|
||||||
|
owner = ["identifier"]
|
||||||
|
|
||||||
|
[object."inbuxa:Gadget"]
|
||||||
|
file = "inbuxa_gadget.rs"
|
||||||
|
default = "none"
|
||||||
|
[object."inbuxa:Gadget".properties]
|
||||||
|
remoteIp = ["network"]
|
||||||
|
|
||||||
|
[source."widget-log"]
|
||||||
|
categories = ["network"]
|
||||||
|
where = ["log-file"]
|
||||||
|
scope = "server"
|
||||||
|
retention = "unbounded"
|
||||||
|
enabled_by = ["x:Widget.enable", "inbuxa:Gadget.remoteIp"]
|
||||||
|
leaves_host = false
|
||||||
|
written_by = ["crates/widget.rs"]
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def make_tree(fields):
|
||||||
|
root = tempfile.mkdtemp()
|
||||||
|
os.makedirs(os.path.join(root, 'resources/schema'))
|
||||||
|
with gzip.open(os.path.join(root, check.SCHEMA), 'wt') as f:
|
||||||
|
json.dump({'fields': fields}, f)
|
||||||
|
os.makedirs(os.path.join(root, check.OBJECTS_DIR))
|
||||||
|
with open(os.path.join(root, check.OBJECTS_DIR, 'inbuxa_gadget.rs'), 'w') as f:
|
||||||
|
f.write('Property::RemoteIp => "remoteIp",\nProperty::Id => "id",\n')
|
||||||
|
os.makedirs(os.path.join(root, 'crates/jmap-proto/src/request'), exist_ok=True)
|
||||||
|
with open(os.path.join(root, check.METHODS), 'w') as f:
|
||||||
|
f.write('"inbuxa:Gadget" => MethodObject::Gadget,\n')
|
||||||
|
with open(os.path.join(root, 'crates/widget.rs'), 'w') as f:
|
||||||
|
f.write('')
|
||||||
|
return root
|
||||||
|
|
||||||
|
|
||||||
|
def prop(type_, fmt=None):
|
||||||
|
t = {'type': type_}
|
||||||
|
if fmt:
|
||||||
|
t['format'] = fmt
|
||||||
|
return {'type': t}
|
||||||
|
|
||||||
|
|
||||||
|
FIELDS = {
|
||||||
|
'x:Widget': {'properties': {
|
||||||
|
'owner': prop('string', 'emailAddress'),
|
||||||
|
'enable': prop('boolean'),
|
||||||
|
'keepFor': prop('number', 'duration'),
|
||||||
|
'label': prop('string', 'string'),
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class PrivacyCheck(unittest.TestCase):
|
||||||
|
def run_check(self, catalog, fields=FIELDS):
|
||||||
|
return check.findings(make_tree(fields), tomllib.loads(catalog))
|
||||||
|
|
||||||
|
def test_the_repository_passes(self):
|
||||||
|
with open(os.path.join(check.ROOT, check.CATALOG), 'rb') as f:
|
||||||
|
catalog = tomllib.load(f)
|
||||||
|
self.assertEqual(check.findings(check.ROOT, catalog), [])
|
||||||
|
|
||||||
|
def test_a_consistent_catalog_passes(self):
|
||||||
|
self.assertEqual(self.run_check(GOOD), [])
|
||||||
|
|
||||||
|
def test_an_unclassified_object_fails(self):
|
||||||
|
fields = dict(FIELDS, **{'x:Sprocket': {'properties': {'size': prop('number', 'size')}}})
|
||||||
|
found = self.run_check(GOOD, fields)
|
||||||
|
self.assertEqual(found, ['x:Sprocket: schema object has no catalog entry'])
|
||||||
|
|
||||||
|
def test_an_address_hidden_behind_the_default_fails(self):
|
||||||
|
fields = {'x:Widget': {'properties': dict(FIELDS['x:Widget']['properties'],
|
||||||
|
contactEmail=prop('string', 'emailAddress'))}}
|
||||||
|
found = self.run_check(GOOD, fields)
|
||||||
|
self.assertEqual(found, ['x:Widget.contactEmail: typed as identifier but not listed'])
|
||||||
|
|
||||||
|
def test_a_secret_in_a_set_or_object_counts(self):
|
||||||
|
fields = {'x:Widget': {'properties': dict(
|
||||||
|
FIELDS['x:Widget']['properties'],
|
||||||
|
ips={'type': {'type': 'set', 'class': {'type': 'string', 'format': 'ipNetwork'}}},
|
||||||
|
key={'type': {'type': 'object', 'objectName': 'x:SecretKey'}},
|
||||||
|
)}}
|
||||||
|
found = self.run_check(GOOD, fields)
|
||||||
|
self.assertIn('x:Widget.ips: typed as network but not listed', found)
|
||||||
|
self.assertIn('x:Widget.key: typed as credential but not listed', found)
|
||||||
|
|
||||||
|
def test_a_stale_property_fails(self):
|
||||||
|
found = self.run_check(GOOD.replace('owner = ["identifier"]', 'owner = ["identifier"]\ngone = ["content"]'))
|
||||||
|
self.assertEqual(found, ['x:Widget.gone: no such property (stale)'])
|
||||||
|
|
||||||
|
def test_a_stale_object_fails(self):
|
||||||
|
found = self.run_check(GOOD + '\n[object."x:Removed"]\ndefault = "none"\n')
|
||||||
|
self.assertEqual(found, ['x:Removed: no such schema object (stale)'])
|
||||||
|
|
||||||
|
def test_a_stale_setting_fails(self):
|
||||||
|
found = self.run_check(GOOD.replace('x:Widget.keepFor', 'x:Widget.keepForever'))
|
||||||
|
self.assertEqual(found, ['x:Widget: setting x:Widget.keepForever names no property of x:Widget'])
|
||||||
|
|
||||||
|
def test_a_stale_code_path_fails(self):
|
||||||
|
found = self.run_check(GOOD.replace('crates/widget.rs', 'crates/gone.rs'))
|
||||||
|
self.assertEqual(found, ['source "widget-log": written_by crates/gone.rs doesn\'t exist (stale)'])
|
||||||
|
|
||||||
|
def test_an_unlisted_inbuxa_object_fails(self):
|
||||||
|
catalog = VOCAB + """
|
||||||
|
[object."x:Widget"]
|
||||||
|
default = "none"
|
||||||
|
[object."x:Widget".properties]
|
||||||
|
owner = ["identifier"]
|
||||||
|
"""
|
||||||
|
found = self.run_check(catalog)
|
||||||
|
self.assertEqual(found, ['inbuxa:Gadget: inbuxa object has no catalog entry'])
|
||||||
|
|
||||||
|
def test_words_outside_the_vocabulary_fail(self):
|
||||||
|
found = self.run_check(GOOD.replace('owner = ["identifier"]', 'owner = ["personal"]'))
|
||||||
|
self.assertEqual(found, ['x:Widget.owner: "personal" is not in vocabulary.categories'])
|
||||||
|
|
||||||
|
def test_unlisted_prints_a_starting_entry(self):
|
||||||
|
fields = dict(FIELDS, **{'x:Sprocket': {'properties': {'mail': prop('string', 'emailAddress')}}})
|
||||||
|
lines = check.unlisted(make_tree(fields), tomllib.loads(GOOD))
|
||||||
|
self.assertIn('[object."x:Sprocket"]', lines)
|
||||||
|
self.assertIn('mail = ["identifier"]', lines)
|
||||||
|
|
||||||
|
|
||||||
|
class StripReport(unittest.TestCase):
|
||||||
|
def test_an_import_reports_what_is_new_and_unclassified(self):
|
||||||
|
import pathlib
|
||||||
|
strip_spec = importlib.util.spec_from_file_location('strip', os.path.join(HERE, '..', 'strip.py'))
|
||||||
|
strip = importlib.util.module_from_spec(strip_spec)
|
||||||
|
strip_spec.loader.exec_module(strip)
|
||||||
|
with gzip.open(os.path.join(check.ROOT, check.SCHEMA)) as f:
|
||||||
|
upstream = json.load(f)
|
||||||
|
upstream['fields']['x:NewThing'] = {'properties': {}}
|
||||||
|
upstream['fields']['x:UserAccount']['properties']['backupEmail'] = prop('string', 'emailAddress')
|
||||||
|
tree = pathlib.Path(tempfile.mkdtemp())
|
||||||
|
(tree / 'resources/schema').mkdir(parents=True)
|
||||||
|
with gzip.open(tree / 'resources/schema/schema.json.gz', 'wt') as f:
|
||||||
|
json.dump(upstream, f)
|
||||||
|
self.assertEqual(strip.privacy_flags(tree), {
|
||||||
|
'objects': ['x:NewThing'],
|
||||||
|
'fields': ['x:UserAccount.backupEmail (identifier)'],
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user