copyofa 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
copyofa/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ """Preservation and forensic toolkit for music collectors"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib.metadata import version
6
+
7
+ __version__ = version("copyofa")
8
+
9
+ __all__ = ["__version__"]
copyofa/cli.py ADDED
@@ -0,0 +1,211 @@
1
+ """Command-line interface for CopyOfA."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from collections import Counter
9
+ from typing import TYPE_CHECKING, Any
10
+
11
+ import copyofa
12
+ from copyofa import verifier
13
+ from copyofa.errors import CopyOfAError
14
+ from copyofa.importer import import_evidence
15
+
16
+ if TYPE_CHECKING:
17
+ from collections.abc import Sequence
18
+
19
+ from copyofa.importer import ImportResult
20
+
21
+ EXIT_OK = 0
22
+ EXIT_PROBLEM = 1
23
+ EXIT_USAGE = 2
24
+ EXIT_CANCELED = 130
25
+
26
+
27
+ class _StoreOnce(argparse.Action):
28
+ """Store an option's value, refusing the option when it is repeated."""
29
+
30
+ def __call__(
31
+ self,
32
+ parser: argparse.ArgumentParser,
33
+ namespace: argparse.Namespace,
34
+ values: Any,
35
+ option_string: str | None = None,
36
+ ) -> None:
37
+ if getattr(namespace, self.dest) is not None:
38
+ parser.error(f"{option_string} may be given only once")
39
+ setattr(namespace, self.dest, values)
40
+
41
+
42
+ def _parser() -> argparse.ArgumentParser:
43
+ parser = argparse.ArgumentParser(prog="copyofa", description=copyofa.__doc__)
44
+ parser.add_argument("--version", action="version", version=copyofa.__version__)
45
+ commands = parser.add_subparsers(dest="command", metavar="<command>")
46
+ commands.required = True
47
+
48
+ importing = commands.add_parser(
49
+ "import",
50
+ help="preserve selected files and directories as a new Evidence Set",
51
+ description=(
52
+ "Preserve selected files and directories as a new Evidence Set. Each "
53
+ "source goes under data/ by its base name."
54
+ ),
55
+ )
56
+ importing.add_argument(
57
+ "sources", nargs="+", metavar="source", help="a file or directory to preserve"
58
+ )
59
+ importing.add_argument(
60
+ "--to",
61
+ required=True,
62
+ metavar="destination",
63
+ help="the Evidence Set to create; it must not exist, its parent must",
64
+ )
65
+ importing.add_argument(
66
+ "--exclude",
67
+ action="append",
68
+ default=[],
69
+ metavar="pattern",
70
+ help=(
71
+ "exclude files and directories whose name, or path under data/, "
72
+ "matches this shell-style pattern (repeatable)"
73
+ ),
74
+ )
75
+ importing.add_argument(
76
+ "--note", action=_StoreOnce, help="a free-form note about the provenance"
77
+ )
78
+ importing.add_argument(
79
+ "--ref",
80
+ action="append",
81
+ metavar="reference",
82
+ help="an external reference, such as discogs=r123456 (repeatable)",
83
+ )
84
+ importing.add_argument(
85
+ "--json", action="store_true", help="print the result as JSON"
86
+ )
87
+ importing.set_defaults(handler=_import)
88
+
89
+ verifying = commands.add_parser(
90
+ "verify",
91
+ help="check an Evidence Set against its Integrity Baseline",
92
+ description=(
93
+ "Check every file of an Evidence Set against its Integrity Baseline. "
94
+ "Exits 0 when everything is intact and 1 when anything is not."
95
+ ),
96
+ )
97
+ verifying.add_argument("evidence_set", metavar="evidence-set")
98
+ verifying.add_argument(
99
+ "--json", action="store_true", help="print the verification Report as JSON"
100
+ )
101
+ verifying.set_defaults(handler=_verify)
102
+ return parser
103
+
104
+
105
+ def main(argv: Sequence[str] | None = None) -> int:
106
+ args = _parser().parse_args(argv)
107
+ try:
108
+ return args.handler(args)
109
+ except KeyboardInterrupt:
110
+ print("copyofa: canceled", file=sys.stderr)
111
+ return EXIT_CANCELED
112
+
113
+
114
+ def _import(args: argparse.Namespace) -> int:
115
+ def progress(path: str, size: int) -> None:
116
+ print(f"Preserved {_printable(path)} ({_count(size, 'byte')})", file=sys.stderr)
117
+
118
+ try:
119
+ result = import_evidence(
120
+ args.sources,
121
+ args.to,
122
+ excludes=args.exclude,
123
+ note=args.note,
124
+ references=args.ref,
125
+ progress=progress,
126
+ )
127
+ except (CopyOfAError, OSError) as error:
128
+ print(f"copyofa: {error}", file=sys.stderr)
129
+ return EXIT_PROBLEM
130
+ for warning in result.warnings:
131
+ print(f"copyofa: warning: {warning}", file=sys.stderr)
132
+ if args.json:
133
+ print(_json(result.to_json()))
134
+ else:
135
+ _print_import(result)
136
+ return EXIT_OK
137
+
138
+
139
+ def _json(document: dict[str, Any]) -> str:
140
+ """Format a `--json` document; non-ASCII characters are escaped as `\\uXXXX`."""
141
+ return json.dumps(document, indent=2, ensure_ascii=True)
142
+
143
+
144
+ def _print_import(result: ImportResult) -> None:
145
+ exclusions = result.import_record["exclusions"]
146
+ print(f"Accepted Evidence Set {_printable(str(result.evidence_set))}")
147
+ print(f"Preserved {_count(result.files, 'file')}, {_count(result.bytes, 'byte')}")
148
+ print(f"Excluded {_count(len(exclusions), 'item')}")
149
+ for exclusion in exclusions:
150
+ reason = exclusion["reason"].replace("_", " ")
151
+ if "pattern" in exclusion:
152
+ reason = f"{reason} {exclusion['pattern']}"
153
+ print(f" {_printable(exclusion['path'])}: {_printable(reason)}")
154
+
155
+
156
+ def _verify(args: argparse.Namespace) -> int:
157
+ try:
158
+ report = verifier.verify(args.evidence_set)
159
+ except verifier.NotAnEvidenceSetError as error:
160
+ print(f"copyofa: {error}", file=sys.stderr)
161
+ return EXIT_USAGE
162
+ if args.json:
163
+ print(_json(report))
164
+ else:
165
+ _print_verification(report)
166
+ return EXIT_OK if verifier.is_intact(report) else EXIT_PROBLEM
167
+
168
+
169
+ def _print_verification(report: dict[str, Any]) -> None:
170
+ baseline = report["integrity_baseline"]
171
+ print(f"Evidence Set {_printable(report['evidence_set'])}")
172
+ print(f"Integrity Baseline: {'usable' if baseline['usable'] else 'unusable'}")
173
+ for problem in baseline["problems"]:
174
+ print(f" {_printable(problem)}")
175
+ _print_items("Records", report["tag_files"])
176
+ _print_items("Payload", report["payload"])
177
+ print(f"Unexpected files: {len(report['unexpected_files'])}")
178
+ for path in report["unexpected_files"]:
179
+ print(f" {_printable(path)}")
180
+ if report["unexamined_directories"]:
181
+ print("Directories not searched for unexpected files:")
182
+ for item in report["unexamined_directories"]:
183
+ print(f" {_printable(item['path'])} ({_printable(item['reason'])})")
184
+ print(f"Result: {'intact' if verifier.is_intact(report) else 'problems found'}")
185
+
186
+
187
+ def _print_items(title: str, items: list[dict[str, str]]) -> None:
188
+ counts = Counter(item["outcome"] for item in items)
189
+ summary = ", ".join(
190
+ f"{counts[outcome]} {outcome.replace('_', ' ')}"
191
+ for outcome in verifier.OUTCOMES
192
+ if counts[outcome]
193
+ )
194
+ print(f"{title}: {summary or 'none'}")
195
+ for item in items:
196
+ if item["outcome"] == verifier.MATCHING:
197
+ continue
198
+ line = f" {item['outcome'].replace('_', ' ')}: {item['path']}"
199
+ if item["outcome"] != verifier.CHANGED:
200
+ line = f"{line} ({item['reason']})"
201
+ print(_printable(line))
202
+
203
+
204
+ def _count(number: int, noun: str) -> str:
205
+ """Format a count with its noun, such as `1 file` or `2 files`."""
206
+ return f"{number} {noun}" if number == 1 else f"{number} {noun}s"
207
+
208
+
209
+ def _printable(text: str) -> str:
210
+ """Escape characters, such as a line feed in a file name, that break a line."""
211
+ return "".join(char if char.isprintable() else repr(char)[1:-1] for char in text)
copyofa/errors.py ADDED
@@ -0,0 +1,7 @@
1
+ """Errors CopyOfA reports to the collector."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class CopyOfAError(Exception):
7
+ """An expected failure, reported to the collector by its message alone."""
@@ -0,0 +1,100 @@
1
+ """The on-disk layout of an Evidence Set, a BagIt 1.0 bag (see ADR 0001)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ BAGIT_TXT = "bagit.txt"
8
+ BAG_INFO_TXT = "bag-info.txt"
9
+ MANIFEST = "manifest-sha256.txt"
10
+ TAG_MANIFEST = "tagmanifest-sha256.txt"
11
+ PAYLOAD_DIR = "data"
12
+ IMPORT_RECORD = "copyofa/import-record.json"
13
+
14
+ BAGIT_TXT_CONTENT = "BagIt-Version: 1.0\nTag-File-Character-Encoding: UTF-8\n"
15
+
16
+ _OS_METADATA_NAMES = frozenset({".DS_Store", "Icon\r", "Thumbs.db", "desktop.ini"})
17
+
18
+
19
+ def is_os_metadata(name: str) -> bool:
20
+ """Tell whether a file name is an operating system metadata file.
21
+
22
+ Operating systems create and rewrite these while a collector browses folders:
23
+ `.DS_Store`, AppleDouble files (`._*`), `Icon` followed by a carriage return,
24
+ `Thumbs.db`, and `desktop.ini`.
25
+ """
26
+ return name in _OS_METADATA_NAMES or name.startswith("._")
27
+
28
+
29
+ _ENCODE = re.compile(r"[%\r\n]")
30
+ _ENCODINGS = {"%": "%25", "\r": "%0D", "\n": "%0A"}
31
+
32
+
33
+ def encode_manifest_path(path: str) -> str:
34
+ """Percent-encode the characters RFC 8493 requires in a manifest path."""
35
+ return _ENCODE.sub(lambda match: _ENCODINGS[match.group()], path)
36
+
37
+
38
+ def manifest_line(digest: str, path: str) -> str:
39
+ """Format a manifest line the way `shasum -c` reads it: two spaces apart."""
40
+ return f"{digest} {encode_manifest_path(path)}\n"
41
+
42
+
43
+ class ManifestError(ValueError):
44
+ """A manifest that cannot be parsed."""
45
+
46
+
47
+ _LINE = re.compile(r"([0-9a-fA-F]{64}) (.+)")
48
+ _DECODE = re.compile(r"%(25|0[AaDd])?")
49
+ _DECODINGS = {"25": "%", "0a": "\n", "0d": "\r"}
50
+
51
+
52
+ def decode_manifest_path(path: str) -> str:
53
+ """Undo encode_manifest_path; raise ManifestError on a `%` not encoded."""
54
+
55
+ def decode(match: re.Match[str]) -> str:
56
+ code = match.group(1)
57
+ if code is None:
58
+ raise ManifestError(f"the path {path!r} holds a % that is not encoded")
59
+ return _DECODINGS[code.lower()]
60
+
61
+ return _DECODE.sub(decode, path)
62
+
63
+
64
+ def parse_manifest(data: bytes) -> list[tuple[str, str]]:
65
+ """Parse a manifest into (lowercase digest, decoded path) pairs, in order.
66
+
67
+ Raises ManifestError when the manifest is not UTF-8, a line is not a SHA-256
68
+ digest and a path two spaces apart, or a path is listed twice.
69
+ """
70
+ try:
71
+ text = data.decode()
72
+ except UnicodeDecodeError:
73
+ raise ManifestError("it is not valid UTF-8") from None
74
+ lines = text.split("\n")
75
+ if lines[-1] == "":
76
+ lines.pop()
77
+ entries = []
78
+ seen = set()
79
+ for number, line in enumerate(lines, start=1):
80
+ match = _LINE.fullmatch(line.removesuffix("\r"))
81
+ if match is None:
82
+ raise ManifestError(f"line {number} is not a SHA-256 digest and a path")
83
+ path = decode_manifest_path(match.group(2))
84
+ if path in seen:
85
+ raise ManifestError(f"line {number} lists {path!r} a second time")
86
+ seen.add(path)
87
+ entries.append((match.group(1).lower(), path))
88
+ return entries
89
+
90
+
91
+ def is_bag_path(path: str) -> bool:
92
+ """Tell whether a manifest path is normalized, relative, and inside the bag."""
93
+ return "\0" not in path and all(
94
+ part not in {"", ".", ".."} for part in path.split("/")
95
+ )
96
+
97
+
98
+ def is_payload_path(path: str) -> bool:
99
+ """Tell whether a manifest path names a file under data/."""
100
+ return is_bag_path(path) and path.startswith(f"{PAYLOAD_DIR}/")