yace-codec 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.
yace_codec/__init__.py ADDED
@@ -0,0 +1,71 @@
1
+ """YACE (Yet Another Custom Encoder): a reversible text encoder built on random
2
+ substitution tables.
3
+
4
+ >>> import yace_codec as yace
5
+ >>> codec = yace.Codec(yace.KeyStore.in_memory())
6
+ >>> token = codec.encode("Hello, World!")
7
+ >>> codec.decode(token)
8
+ 'Hello, World!'
9
+
10
+ YACE obfuscates text. It is not encryption: do not use it to protect secrets.
11
+ """
12
+
13
+ from .codec import (
14
+ DEFAULT_FAMILY,
15
+ DEFAULT_ITERATIONS,
16
+ DEFAULT_LENGTH,
17
+ MAX_ENCODED_CHARS,
18
+ MAX_ITERATIONS,
19
+ Codec,
20
+ Header,
21
+ decode,
22
+ decode_bytes,
23
+ encode,
24
+ encode_bytes,
25
+ parse_header,
26
+ )
27
+ from .errors import (
28
+ DecodingError,
29
+ EncodingError,
30
+ InvalidNameError,
31
+ KeyStoreError,
32
+ TableNotFoundError,
33
+ UnsupportedCharacterError,
34
+ YaceError,
35
+ )
36
+ from .keystore import ENV_VAR, Family, ImportResult, KeyStore, default_keystore_path
37
+ from .tables import MAX_LENGTH, MIN_LENGTH, SUPPORTED_CHARACTERS, Table
38
+
39
+ __version__ = "0.1.0"
40
+
41
+ __all__ = [
42
+ "DEFAULT_FAMILY",
43
+ "DEFAULT_ITERATIONS",
44
+ "DEFAULT_LENGTH",
45
+ "ENV_VAR",
46
+ "MAX_ENCODED_CHARS",
47
+ "MAX_ITERATIONS",
48
+ "MAX_LENGTH",
49
+ "MIN_LENGTH",
50
+ "SUPPORTED_CHARACTERS",
51
+ "Codec",
52
+ "DecodingError",
53
+ "EncodingError",
54
+ "Family",
55
+ "Header",
56
+ "ImportResult",
57
+ "InvalidNameError",
58
+ "KeyStore",
59
+ "KeyStoreError",
60
+ "Table",
61
+ "TableNotFoundError",
62
+ "UnsupportedCharacterError",
63
+ "YaceError",
64
+ "__version__",
65
+ "decode",
66
+ "decode_bytes",
67
+ "default_keystore_path",
68
+ "encode",
69
+ "encode_bytes",
70
+ "parse_header",
71
+ ]
yace_codec/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Allow ``python -m yace_codec``."""
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
yace_codec/cli.py ADDED
@@ -0,0 +1,276 @@
1
+ """Command-line interface: ``yace-codec`` (or ``python -m yace_codec``)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import contextlib
7
+ import errno
8
+ import json
9
+ import os
10
+ import sys
11
+ from pathlib import Path
12
+ from typing import Callable, Dict, List, Optional, Sequence
13
+
14
+ from . import __version__
15
+ from .codec import DEFAULT_FAMILY, DEFAULT_ITERATIONS, DEFAULT_LENGTH, Codec
16
+ from .errors import KeyStoreError, UnsupportedCharacterError, YaceError
17
+ from .keystore import ENV_VAR, KeyStore, family_of
18
+
19
+ PROG = "yace-codec"
20
+
21
+
22
+ def main(argv: Optional[Sequence[str]] = None) -> int:
23
+ """Run the command line and return the exit status."""
24
+ parser = _build_parser()
25
+ args = parser.parse_args(argv)
26
+ if getattr(args, "text", None) is not None and args.input != "-":
27
+ parser.error("give either INPUT or --text, not both")
28
+ handler: Callable[[argparse.Namespace], int] = args.handler
29
+ try:
30
+ return handler(args)
31
+ except UnsupportedCharacterError as exc:
32
+ shown = ", ".join(f"{ascii(ch)} (U+{ord(ch):04X})" for ch in exc.characters[:10])
33
+ if len(exc.characters) > 10:
34
+ shown += f" and {len(exc.characters) - 10} more"
35
+ return _fail(f"table {exc.table_name!r} cannot encode {shown}; re-run with --binary")
36
+ except UnicodeDecodeError:
37
+ return _fail("input is not valid UTF-8 text; re-run with --binary")
38
+ except YaceError as exc:
39
+ return _fail(str(exc))
40
+ except OSError as exc:
41
+ if isinstance(exc, BrokenPipeError):
42
+ _silence_stdout()
43
+ return 1
44
+ target = f"{exc.filename}: " if exc.filename else ""
45
+ return _fail(f"{target}{exc.strerror or exc}")
46
+ except KeyboardInterrupt:
47
+ return 130
48
+
49
+
50
+ def _build_parser() -> argparse.ArgumentParser:
51
+ store = argparse.ArgumentParser(add_help=False)
52
+ store.add_argument(
53
+ "-k",
54
+ "--keystore",
55
+ metavar="PATH",
56
+ help=f"key store file (default: ${ENV_VAR} if set, else a file in your user data "
57
+ "directory; see 'yace-codec tables path')",
58
+ )
59
+
60
+ parser = argparse.ArgumentParser(
61
+ prog=PROG,
62
+ description="Encode and decode text with YACE substitution tables. "
63
+ "YACE obfuscates text; it is not encryption.",
64
+ )
65
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
66
+ commands = parser.add_subparsers(title="commands", metavar="COMMAND")
67
+ commands.required = True
68
+
69
+ enc = commands.add_parser("encode", parents=[store], help="encode text or a file")
70
+ enc.add_argument("input", nargs="?", default="-", help="file to encode (default: stdin)")
71
+ enc.add_argument("-t", "--text", help="encode this string instead of INPUT")
72
+ enc.add_argument("-o", "--output", metavar="PATH", help="write the token here (default: stdout)")
73
+ enc.add_argument(
74
+ "-n", "--name", default=DEFAULT_FAMILY, help="table family name (default: %(default)s)"
75
+ )
76
+ enc.add_argument(
77
+ "-l", "--length", type=int, default=DEFAULT_LENGTH,
78
+ help="code length, 2-62 (default: %(default)s)",
79
+ )
80
+ enc.add_argument(
81
+ "-i", "--iterations", type=int, default=DEFAULT_ITERATIONS,
82
+ help="number of encoding layers (default: %(default)s)",
83
+ )
84
+ enc.add_argument(
85
+ "-b", "--binary", action="store_true",
86
+ help="encode the input as raw bytes (any file type or text encoding)",
87
+ )
88
+ enc.set_defaults(handler=_cmd_encode)
89
+
90
+ dec = commands.add_parser("decode", parents=[store], help="decode a token")
91
+ dec.add_argument("input", nargs="?", default="-", help="file holding the token (default: stdin)")
92
+ dec.add_argument("-t", "--text", help="decode this token string instead of INPUT")
93
+ dec.add_argument("-o", "--output", metavar="PATH", help="write the result here (default: stdout)")
94
+ dec.add_argument(
95
+ "-b", "--binary", action="store_true",
96
+ help="the token was made with 'encode --binary'; output the raw bytes",
97
+ )
98
+ dec.set_defaults(handler=_cmd_decode)
99
+
100
+ tables = commands.add_parser("tables", help="inspect and share key store tables")
101
+ actions = tables.add_subparsers(title="actions", metavar="ACTION")
102
+ actions.required = True
103
+ path = actions.add_parser("path", parents=[store], help="print the key store location")
104
+ path.set_defaults(handler=_cmd_path)
105
+ listing = actions.add_parser("list", parents=[store], help="list table families")
106
+ listing.set_defaults(handler=_cmd_list)
107
+ export = actions.add_parser("export", parents=[store], help="export a family's tables as JSON")
108
+ export.add_argument("name", help="family name")
109
+ export.add_argument("-o", "--output", metavar="PATH", help="write here (default: stdout)")
110
+ export.set_defaults(handler=_cmd_export)
111
+ imp = actions.add_parser("import", parents=[store], help="import tables from a JSON file")
112
+ imp.add_argument("file", help="JSON file from 'tables export' ('-' for stdin)")
113
+ which = imp.add_mutually_exclusive_group()
114
+ which.add_argument("--family", metavar="NAME", help="import only this family's tables")
115
+ which.add_argument(
116
+ "--all-families", action="store_true",
117
+ help="import every table in a file that holds several families",
118
+ )
119
+ imp.add_argument(
120
+ "--overwrite", action="store_true",
121
+ help="replace existing tables that differ (tokens made with them stop decoding)",
122
+ )
123
+ imp.set_defaults(handler=_cmd_import)
124
+ return parser
125
+
126
+
127
+ def _keystore(args: argparse.Namespace) -> KeyStore:
128
+ return KeyStore(args.keystore) if args.keystore else KeyStore.default()
129
+
130
+
131
+ def _read_input(args: argparse.Namespace) -> bytes:
132
+ if args.text is not None:
133
+ # The argument's original bytes: on POSIX, bytes that are not valid
134
+ # UTF-8 arrive as surrogates, which os.fsencode turns back into bytes.
135
+ return os.fsencode(args.text)
136
+ if args.input == "-":
137
+ return sys.stdin.buffer.read()
138
+ return _read_file(args.input)
139
+
140
+
141
+ def _read_file(name: str) -> bytes:
142
+ if os.path.isdir(name):
143
+ raise IsADirectoryError(errno.EISDIR, "is a directory", name)
144
+ return Path(name).read_bytes()
145
+
146
+
147
+ def _write_output(target: Optional[str], data: bytes, *, private: bool = False) -> None:
148
+ if target is None or target == "-":
149
+ _write_stdout(data)
150
+ return
151
+ if os.path.isdir(target):
152
+ raise IsADirectoryError(errno.EISDIR, "is a directory", target)
153
+ flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC | getattr(os, "O_BINARY", 0)
154
+ fd = os.open(target, flags, 0o600 if private else 0o666)
155
+ if private and sys.platform != "win32":
156
+ os.fchmod(fd, 0o600) # the open() mode only applies to new files
157
+ with os.fdopen(fd, "wb") as fh:
158
+ fh.write(data)
159
+
160
+
161
+ def _cmd_encode(args: argparse.Namespace) -> int:
162
+ codec = Codec(_keystore(args))
163
+ raw = _read_input(args)
164
+ options = dict(length=args.length, iterations=args.iterations)
165
+ if args.binary:
166
+ token = codec.encode_bytes(raw, args.name, **options)
167
+ else:
168
+ token = codec.encode(raw.decode("utf-8"), args.name, **options)
169
+ _write_output(args.output, f"{token}\n".encode("ascii"))
170
+ return 0
171
+
172
+
173
+ def _cmd_decode(args: argparse.Namespace) -> int:
174
+ codec = Codec(_keystore(args))
175
+ raw = _read_input(args)
176
+ if args.binary:
177
+ result = codec.decode_bytes(raw)
178
+ else:
179
+ result = codec.decode(raw).encode("utf-8")
180
+ _write_output(args.output, result)
181
+ return 0
182
+
183
+
184
+ def _cmd_path(args: argparse.Namespace) -> int:
185
+ _echo(str(_keystore(args).path))
186
+ return 0
187
+
188
+
189
+ def _cmd_list(args: argparse.Namespace) -> int:
190
+ store = _keystore(args)
191
+ families = store.families()
192
+ if not families:
193
+ _echo(f"no tables in {store.path}", err=True)
194
+ return 0
195
+ rows: List[Dict[str, str]] = [
196
+ {"name": f.name, "length": str(f.length), "layers": ",".join(map(str, f.layers))}
197
+ for f in families
198
+ ]
199
+ width = max(len("NAME"), *(len(r["name"]) for r in rows))
200
+ _echo(f"{'NAME':<{width}} LENGTH LAYERS")
201
+ for r in rows:
202
+ _echo(f"{r['name']:<{width}} {r['length']:<6} {r['layers']}")
203
+ return 0
204
+
205
+
206
+ def _cmd_export(args: argparse.Namespace) -> int:
207
+ tables = _keystore(args).export_family(args.name)
208
+ payload = json.dumps(tables, indent=2, ensure_ascii=True) + "\n"
209
+ _write_output(args.output, payload.encode("ascii"), private=True)
210
+ return 0
211
+
212
+
213
+ def _cmd_import(args: argparse.Namespace) -> int:
214
+ raw = sys.stdin.buffer.read() if args.file == "-" else _read_file(args.file)
215
+ try:
216
+ # json.loads detects UTF-8 (with or without a BOM), UTF-16, and UTF-32,
217
+ # so files saved by Windows PowerShell 5.1 work too.
218
+ tables = json.loads(raw)
219
+ except (ValueError, RecursionError) as exc:
220
+ raise KeyStoreError(f"{args.file} is not valid JSON ({exc})") from exc
221
+ if isinstance(tables, dict) and args.family is None and not args.all_families:
222
+ # A shared file could add tables for families you use, such as "default",
223
+ # so that its sender can decode your future tokens: name the family you expect.
224
+ families = sorted({f for f in map(family_of, tables) if f is not None})
225
+ held = ", ".join(families) or "no valid tables"
226
+ raise KeyStoreError(
227
+ f"{args.file} holds tables for: {held}. Name the family you expect with "
228
+ "--family NAME, or import everything with --all-families"
229
+ )
230
+ store = _keystore(args)
231
+ result = store.import_tables(tables, overwrite=args.overwrite, family=args.family)
232
+ for label, names in (("added", result.added), ("replaced", result.replaced)):
233
+ for name in names:
234
+ _echo(f"{label}: {name}", err=True)
235
+ _echo(
236
+ f"{len(result.added)} added, {len(result.unchanged)} unchanged, "
237
+ f"{len(result.replaced)} replaced ({args.keystore or store.path})",
238
+ err=True,
239
+ )
240
+ return 0
241
+
242
+
243
+ def _fail(message: str) -> int:
244
+ _echo(f"{PROG}: error: {message}", err=True)
245
+ return 1
246
+
247
+
248
+ def _echo(text: str, *, err: bool = False) -> None:
249
+ # Write UTF-8 bytes so redirected output never fails on the console code page.
250
+ data = (text + "\n").encode("utf-8", "backslashreplace")
251
+ if not err:
252
+ _write_stdout(data)
253
+ return
254
+ with contextlib.suppress(OSError): # nowhere left to report a failure
255
+ sys.stderr.flush()
256
+ sys.stderr.buffer.write(data)
257
+ sys.stderr.buffer.flush()
258
+
259
+
260
+ def _write_stdout(data: bytes) -> None:
261
+ try:
262
+ sys.stdout.flush()
263
+ sys.stdout.buffer.write(data)
264
+ sys.stdout.buffer.flush()
265
+ except OSError as exc:
266
+ # A reader that closed the pipe: EPIPE on POSIX, EINVAL on Windows.
267
+ if isinstance(exc, BrokenPipeError) or exc.errno == errno.EINVAL:
268
+ raise BrokenPipeError(errno.EPIPE, "broken pipe") from exc
269
+ raise
270
+
271
+
272
+ def _silence_stdout() -> None:
273
+ # Python flushes stdout at exit; point it at devnull so a closed pipe stays quiet.
274
+ with contextlib.suppress(OSError, ValueError):
275
+ devnull = os.open(os.devnull, os.O_WRONLY)
276
+ os.dup2(devnull, sys.stdout.fileno())