diff --git a/CHANGELOG.md b/CHANGELOG.md index aac7c38..9cb2db1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,8 +11,37 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). `Петренко;Іван;Петрович;сержант`): a `;` with no space after it now separates cells like `|` does, so the name, the rank and the patronymic in the last cell are masked. Before, the patronymic glued to the next cell - stayed open. A `;` followed by a space still ends a name, as in prose. A - comma glued to the next word (`Петрович,сержант`) is read as `, `. + stayed open. A `;` followed by a space still ends a name, as in prose. + CSV cells separated by a comma with no space (`Петренко,Іван,Петрович,сержант`) + and quoted cells (`"Петренко";"Іван";"Петрович"`, `"Петренко","Іван"`) are + read the same way; before, such names stayed open. A comma followed by a + space still ends a name. +- A surname with an invisible character inside it (a soft hyphen, a + zero-width space or joiner, a byte order mark left in the middle of joined + files — common in text copied from Word, PDF or web pages), or with `‘` or + `` ` `` typed for the apostrophe (`ДЕМ‘ЯНЧУК`, `П`ЯТНИЧУК`), could stay + partly or fully open, and its occurrences were not restored by unmasking. + Such characters are now part of the word wherever names are searched, the + mask is made from the word without them, and the mapping keeps the + spelling of the document. +- Dates after `законодавства`, `законних` or a law mentioned earlier in the + sentence (`на підставі закону, рапорт від 01.02.2025`) were taken for the + date of a law and stayed open. A date is kept only right after the name of + an act: its number, `України`, a quoted title of any length and a line + break may come in between, a comma may not. The date of a law with a long + quoted title, or with `від` on the next line, is now kept as well; before + it was shifted. +- `БР566` and `БР: 566` stayed open; `БР 566` and `БР-566` were masked. +- In a list of order numbers (`накази № 12, 13 та 14`) only the first number + was masked. +- `наказ № 15.01.2025`: the day was masked as an order number, and the month + and year stayed open. It is now masked as a date. +- Unit numbers written with a non-breaking space or a hyphen + (`в/ч А 1234` with a non-breaking space, `в/ч А-1234`) stayed open. +- With `--only passport` (or the military ID type turned off), old passports + (`АА123456`, `АА 123456`) stayed open: they were masked only as military IDs. +- Text dates in single quotes (`'28' вересня 2025`, `‘28’ вересня 2025`) + were not recognised. - A Latin letter that looks like a Cyrillic one inside a name (`Ярoслав` with a Latin `o`, typical of text pasted from elsewhere) made the name unrecognised. Its mask could then be the real name (`Ярослав`), and a @@ -47,6 +76,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). came back as `майору`). The nearest free rank of the hierarchy is now taken. When every rank of the hierarchy is open in the document, the rank is left as it is rather than masked with a rank that unmasking would confuse. +- A one-digit order number (`наказ № 5`) in a document with open numbered + items 1–9 got a mask equal to one of the item numbers, and unmasking + swapped them. Such a number now gets a two-digit mask when every one-digit + mask is taken. +- A text date with no space before the month (`«28»вересня 2025 року`, + `28вересня 2025`) was masked but not restored. ### Fixed — older mapping files - A v2.0 mapping (entries without `instances`) restored nothing, with exit @@ -71,6 +106,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). and `-ін` families also cover `Литвином`, `Литвинові` and `Кузьміним`. A bare surname ending in a consonant gets a mask ending in a consonant where possible. +- Surnames declined like soft adjectives (`Задорожній`, `Задорожнього`, + `Задорожньому`, `Задорожнім`, `Задорожня`, `Задорожньої`) got a different + stem in each case, and `Задорожнім` lost its ending. Feminine adjective + surnames in the dative (`Головатій` next to `Головата`, `Головатої`) also + got a different stem. They now share the stem of the other forms; a soft + ending stays soft only after `н` (`Задаштого`, not `Задаштього`). +- Text dates kept the day when the shift went past the 1st or the 28th + (`1 березня` → `1 …`, `28 вересня` → `28 …`); the shift now goes the other + way. +- `У 2025 році`, `В 2024 р.`, `А 2000 грн` were masked as military unit + numbers (`У 9309 році`). A letter and four digits with a space between them + are now a unit number only after `в/ч` or `частина`; `А1234` is always one. - A surname mask could contain the apostrophe `ʼ` (U+02BC) from the name dictionary. A document in cp1251 with such a mask then failed to save. Surname masks now use the ASCII apostrophe, as name masks already did. @@ -99,6 +146,40 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed — command line - `--only ,` (an empty list) enabled every type; an empty `--only` or `--exclude` list is now an error (exit code 2). +- `--log-file` naming the input file, the output file or the mapping wrote + log lines into it: into the original document (they then appeared in the + masked output) or into the mapping (an encrypted mapping could no longer be + decrypted). This is now an error (exit code 2). +- `data-unmask` accepted any JSON object as a mapping (a configuration file, + `{}`, the masked JSON itself) and reported success. A file without + `mappings` is now rejected, as is a mask that is only spaces or punctuation + (unmasking inserted originals between words) and a malformed legacy + `re_mask_passes` key (a traceback before). +- A mapping saved by an editor with a UTF-8 byte order mark is now read. +- A mapping larger than 100 MB could not be read even with a higher size + limit in the configuration, so a long `--re-mask` chain could not be + unmasked. The limit for a mapping is now 30 times the configured input + limit. +- In automatic mode (no masked file given) `data-unmask` ignored `-o` and + `--map`; they now apply. +- `base64`, `hex`, `rot13` and other codecs that are not text encodings, in + the configuration or in the mapping, gave a traceback; they are now a + configuration error. JSON nested too deeply to process is an error too + (exit code 1), not a traceback. +- `-o` naming a directory said to use `--force`; it is now an error saying + that it is a directory. `-o` in a directory that does not exist failed only + after all the masking; `data-mask` now checks it first. +- A forged encrypted mapping (format 2) could ask for scrypt parameters that + take about a minute of computing before the wrong-password error; such + parameters are now rejected. + +### Performance +- Text dates were searched with a pattern that took quadratic time on long + runs of spaces or empty lines, in masking and unmasking (80 000 spaces in a + document: about 4 minutes). +- Files whose lines end in a carriage return only (old Mac files) or in + U+2028 / U+2029 were processed as one long line, tens of times slower; their + lines are now processed one by one. ## [3.1.21] - 2026-11 diff --git a/datamasking/_fsutil.py b/datamasking/_fsutil.py index c3d4d8d..6c2418d 100644 --- a/datamasking/_fsutil.py +++ b/datamasking/_fsutil.py @@ -25,6 +25,18 @@ SECURE_DELETE_TEMP = True +def same_file(a: Union[str, Path], b: Union[str, Path]) -> bool: + """Чи *a* і *b* — той самий файл (зокрема через посилання); для + неіснуючих — той самий абсолютний шлях.""" + try: + pa, pb = Path(a), Path(b) + if pa.exists() and pb.exists(): + return os.path.samefile(pa, pb) + return pa.resolve() == pb.resolve() + except (OSError, ValueError): + return False + + def _discard_temp(tmp_name: str) -> None: try: if SECURE_DELETE_TEMP: diff --git a/datamasking/_textio.py b/datamasking/_textio.py index 715af35..608d821 100644 --- a/datamasking/_textio.py +++ b/datamasking/_textio.py @@ -24,9 +24,13 @@ def normalize_encoding(name: str) -> str: """Канонічна назва кодування Python ('CP1251' → 'cp1251'); ValueError, якщо невідоме.""" try: - return codecs.lookup(str(name).strip()).name + info = codecs.lookup(str(name).strip()) except LookupError: raise ValueError(f"unknown encoding: {name!r}") from None + # base64, hex, rot13 … — кодеки не тексту: до 3.1.22 давали traceback + if not getattr(info, "_is_text_encoding", True): + raise ValueError(f"not a text encoding: {name!r}") + return info.name def check_encoding_settings(encoding: str, allowed: Optional[Iterable[str]]) -> Tuple[str, List[str]]: diff --git a/datamasking/extras/security.py b/datamasking/extras/security.py index bbbabb7..c535a04 100644 --- a/datamasking/extras/security.py +++ b/datamasking/extras/security.py @@ -121,6 +121,12 @@ def as_int(key: str, default: int, lo: int, hi: int) -> int: out["p"] = as_int("p", 1, 1, 16) if 128 * out["n"] * out["r"] > MappingSecurityManager.SCRYPT_MAX_MEMORY: raise ValueError("security.scrypt_n × scrypt_r needs more than 1 GiB of memory") + # Обсяг обчислень (× p): до 3.1.22 підроблений заголовок .enc з + # n=2^20, r=8, p=16 змушував рахувати ключ ~40 с, перш ніж + # з'являлось «Decryption failed» + if 128 * out["n"] * out["r"] * out["p"] > 2 * MappingSecurityManager.SCRYPT_MAX_MEMORY: + raise ValueError("security.scrypt_n × scrypt_r × scrypt_p is too large " + "(key derivation would take minutes)") return out @classmethod diff --git a/datamasking/masking/cli.py b/datamasking/masking/cli.py index 8f8b249..0d458bf 100644 --- a/datamasking/masking/cli.py +++ b/datamasking/masking/cli.py @@ -18,7 +18,7 @@ from datetime import datetime from typing import Any, Dict, List, Optional, Tuple -from datamasking._fsutil import atomic_write_private +from datamasking._fsutil import atomic_write_private, same_file from datamasking._textio import read_text, check_encoding_settings from datamasking.masking import constants as _cfg from datamasking.masking.helpers import validate_file_size @@ -390,6 +390,13 @@ def _setup_logger(args, config) -> Optional[Any]: _config_warning(args, f"{e} — log rotation disabled") max_bytes = 0 + # Лог не пишеться у вхідний файл чи результат: до 3.1.22 «--log-file + # input.txt» дописував рядки логу в оригінал, і вони потрапляли у вихід + if log_file: + for label, other in (("input", args.input), ("output", args.output)): + if other and same_file(log_file, other): + print(f"Error: log file {log_file} is the {label} file", file=sys.stderr) + sys.exit(2) try: logger = setup_logging(level=log_level, log_file=log_file, console=console, file_format=file_format, max_bytes=max_bytes, @@ -854,8 +861,14 @@ def _validate_output_paths(args, input_path: Path, output_path: Path) -> Optiona same = False if same: return f"Output file must differ from input file: {output_path}" + # До 3.1.22 каталог на місці -o без --force підказував «use --force», а + # неіснуюча тека виявлялась лише після всього маскування + if output_path.is_dir(): + return f"Output path is a directory: {output_path}" if output_path.exists() and not getattr(args, 'force', False): return f"Output file already exists: {output_path} (use --force to overwrite)" + if not output_path.parent.is_dir(): + return f"Output directory does not exist: {output_path.parent}" return None @@ -1281,6 +1294,15 @@ def _save_results(masked_data, is_json: bool, masking_dict: Dict, def main(argv=None) -> int: """CLI entry point. Повертає код виходу (0/1/2); обгортки роблять sys.exit(main()), тому помилки більше не завершуються exit 0.""" + try: + return _main(argv) + except RecursionError: + # Надто глибоко вкладений JSON (до 3.1.22 — traceback) + print("Error: the data is nested too deeply to process", file=sys.stderr) + return 1 + + +def _main(argv=None) -> int: # Fix Unicode output on Windows (cp1252 console): інакше «✅» у підсумку # падав UnicodeEncodeError уже ПІСЛЯ запису файлів (unmask мав цей фікс, mask — ні) if sys.platform == 'win32' and getattr(sys.stdout, 'encoding', 'utf-8').lower().replace('-', '') != 'utf8': diff --git a/datamasking/masking/constants.py b/datamasking/masking/constants.py index eb9aca8..210ea10 100644 --- a/datamasking/masking/constants.py +++ b/datamasking/masking/constants.py @@ -190,10 +190,13 @@ def set_faker_locale(locale: str) -> None: r'січня|лютого|березня|квітня|травня|червня|' r'липня|серпня|вересня|жовтня|листопада|грудня' ) +# Лапки — і одинарні ('28', ‘28’); пробіли — лише після лапки: до 3.1.22 +# «["“„«]?\s*» на початку давав квадратичний час на довгих послідовностях +# пробілів чи порожніх рядків (80 000 пробілів — 4 хвилини) DATE_TEXT_PATTERN = re.compile( - r'(?:["\u201c\u201e«]?\s*)?' # optional opening quote + r'(?:["\u201c\u201e«\'\u2018]\s*)?' # optional opening quote r'(? None: # відкритим), а «Петрович)» вважалось — разом із дужкою, що зникала з тексту BRACKET_CHARS = '()[]{}<>' WRAP_CHARS = QUOTE_CHARS + BRACKET_CHARS + '*/' +# Знаки всередині слова, що не є літерами: апостроф у будь-якому написанні +# (ASCII «'», «’», «ʼ», а також «‘» і «`», які ставлять замість нього) і +# невидимі символи з документів Word, PDF і вебсторінок (м'який перенос, +# zero-width space / non-joiner / joiner, word joiner, а також BOM посеред +# тексту — він лишається, коли файли з BOM склеюють). До 3.1.22 фаза +# «прізвище + ініціали» вважала «‘», «`» і невидимі символи межею слова й +# маскувала лише хвіст прізвища («ЗІНЧ\u200bЕНКО» → «ЗІНЧ\u200bЕНАНЕЦЬ»), +# а основний розбір маскував усе слово ще раз — unmask не відновлював оригінал +APOSTROPHE_CHARS = "'’ʼ‘`" +INVISIBLE_CHARS = "\u00ad\u200b\u200c\u200d\u2060\ufeff" +# «‘», «`» і невидимі — частина слова лише між літерами (на початку слова «‘» — лапка) +JOINER_CHARS = "‘`" + INVISIBLE_CHARS MAX_INPUT_FILE_SIZE = 100 * 1024 * 1024 diff --git a/datamasking/masking/context.py b/datamasking/masking/context.py index 77df9c1..e200460 100644 --- a/datamasking/masking/context.py +++ b/datamasking/masking/context.py @@ -142,6 +142,10 @@ def analyze_number_sign_context(text: str, match: re.Match) -> Optional[Dict]: return None number_text = number_match.group(1) + # «№ 15.01.2025» — це дата (її маскує фаза дат); до 3.1.22 «15» бралось як + # номер, і маска «№ 98.01.2025» лишала місяць і рік відкритими + if re.match(r'\.\d{1,2}\.\d{2,4}(?!\d)', after_text[number_match.end():]): + return None full_text = text[match.start():match.end() + len(number_match.group(0))] # 3. № 123дск / № 45/12/3п — БР; «№ 123-к» (кадровий наказ) з дефісом — @@ -163,7 +167,8 @@ def analyze_br_keyword(text: str, match: re.Match) -> Optional[Dict]: """Аналізує контекст після слова БР""" pos = match.end() after_text = text[pos:pos+100] - br_match = re.match(r'[-\s]?(\d+(?:[/-]\d+)*(?:дск|п|к)?)', after_text, re.IGNORECASE) + # «БР 566», «БР-566», «БР566», «БР: 566» (до 3.1.22 два останні лишались відкритими) + br_match = re.match(r'(?:\s*:\s*|[-\s])?(\d+(?:[/-]\d+)*(?:дск|п|к)?)', after_text, re.IGNORECASE) if br_match: return { 'type': 'br_standalone', @@ -174,6 +179,13 @@ def analyze_br_keyword(text: str, match: re.Match) -> Optional[Dict]: } return None +def _csv_cells(line: str) -> str: + """Клітинки CSV через кому впритул («Петренко,Іван,Петрович,сержант») і + в лапках («"Петренко","Іван"») — як окремі слова.""" + line = re.sub(r'(?<=[^\W\d_]),(?=[^\W\d_])', ' ', line) + return re.sub(r'"\s*[,;]?\s*"', ' ', line) + + def clean_line_before_parsing(line: str) -> str: # «|» таблиць — як пробіл (рушій потім знаходить слова ПІБ у рядку з # оригінальними роздільниками, див. engine.locate_words) @@ -184,6 +196,11 @@ def clean_line_before_parsing(line: str) -> str: # «Петрович;сержант;1234567890» була одним словом, і по батькові # лишалось відкритим line = re.sub(r';(?=\S)', ' ', line) + line = _csv_cells(line) + # Невидимі символи біля пробілу чи краю рядка («\u200cстарший матрос») + # — не частина слова; всередині слова лишаються: рушій шукає слова ПІБ у + # справжньому рядку, і слово без них там не знайшлося б + line = re.sub(r'(?:^|(?<=\s))[' + _cfg.INVISIBLE_CHARS + r']+|[' + _cfg.INVISIBLE_CHARS + r']+(?=\s|$)', '', line) line = re.sub(r',(?=[^\W\d_])', ', ', line) # Видаляємо нумерацію пунктів на початку рядка: "20.1.2.1.", "1.", "1.2.", "3.2." тощо line = re.sub(r'^\s*(?:\d+\.)+\s*', '', line) @@ -232,7 +249,7 @@ def extract_base_rank(full_rank_text: str) -> Tuple[str, str]: def looks_like_pib_line(line: str) -> bool: if not line or len(line.strip()) < 6: return False - line_clean = WORD_SEPARATOR_RE.sub(' ', line).strip() + line_clean = WORD_SEPARATOR_RE.sub(' ', _csv_cells(line)).strip() line_lower = line_clean.lower() if line_clean.startswith('===') or line_clean.startswith('---') or re.match(r'^[А-ЯҐЄІЇA-Z\s]+:\s*$', line_clean): return False diff --git a/datamasking/masking/declension.py b/datamasking/masking/declension.py index 7612632..2f67f69 100644 --- a/datamasking/masking/declension.py +++ b/datamasking/masking/declension.py @@ -21,6 +21,7 @@ from typing import List, NamedTuple, Optional, Set, Tuple from datamasking.masking import constants as _cfg +from datamasking.masking.helpers import clean_word # Відмінки: nominative, genitive (= accusative для істот чоловічого роду), # dative (також місцевий), dative_ovi («Петрові», «Андрієві»), accusative @@ -97,7 +98,7 @@ def fold_homoglyphs(word: str) -> str: def _normalize(word: str) -> str: - return fold_homoglyphs(word.replace("ʼ", "'").replace("’", "'")) + return fold_homoglyphs(clean_word(word)) def _faker_names() -> Tuple[Set[str], Set[str]]: diff --git a/datamasking/masking/engine.py b/datamasking/masking/engine.py index 385cdad..052c21a 100644 --- a/datamasking/masking/engine.py +++ b/datamasking/masking/engine.py @@ -24,7 +24,7 @@ is_name_patronymic_pair, looks_like_pib_line, parse_hybrid_line, WORD_SEPARATOR_RE, ) from datamasking.masking.declension import analyze_patronymic -from datamasking.masking.helpers import get_deterministic_seed, add_to_mapping +from datamasking.masking.helpers import get_deterministic_seed, add_to_mapping, clean_word from datamasking.masking.language import ( is_likely_surname_by_case, detect_gender_by_patronymic, looks_like_name, ) @@ -54,16 +54,24 @@ def _rank_hints(rank: Optional[str]) -> Tuple[Optional[str], Optional[str]]: _UA_UPPER = "АБВГҐДЕЖЗІЙКЛМНОПРСТУФХЦЧШЩЮЯЄІЇҐ" +# Межі слова: не всередині слова — ні після літери, апострофа чи дефіса, ні +# після літери з «‘», «`» чи невидимим символом (див. constants.JOINER_CHARS) +_JOIN = _cfg.JOINER_CHARS +WORD_BEFORE = r"(? bool: if not parts: return False for part in parts: - letters = part.replace("'", "").replace("ʼ", "").replace("’", "") + letters = clean_word(part).replace("'", "") if not letters: return False if letters.isupper() and len(letters) >= 2: @@ -310,11 +318,11 @@ def _get_rank_forms_re() -> Pattern[str]: if _RANK_FORMS_RE is None: forms = sorted(_cfg.ALL_RANK_FORMS, key=len, reverse=True) alternation = '|'.join(re.escape(f).replace(r'\ ', r'\s+') for f in forms) - _RANK_FORMS_RE = re.compile(r"(? Dict[str, int]: @@ -544,6 +552,22 @@ def _mask_quoted_ranks(text: str, masking_dict: Dict, instance_counters: Dict) - return text +# Наступний номер переліку після «№ 12»: «, 13», « та 14»; далі — кінець +# речення/рядка, кома, «та/і/й» чи «від» (не «13 травня», не «13.05.2025») +# Після «та/і/й» номер може бути останнім і перед будь-яким словом, крім +# місяця чи «року» («та 14 скасувати», не «та 14 травня») +_LISTED_NUMBER_RE = re.compile( + r"(?:\s*,\s*(\d+(?:[/-]\d+)*)(?=[ \t]*(?:[,;)\r\n]|\.(?!\d)|(?:та|і|й|від)\b|$))" + r"|\s+(?:та|і|й)\s+(\d+(?:[/-]\d+)*)(?![\d.,/-]?\d)(?![ \t]*(?:" + _cfg._MONTHS_UA + + r"|року|рік|років|р\.)))", re.IGNORECASE) + +# CR перед службовим _LINE_JOIN — це CRLF рядка, склеєного з наступним +_LINE_SPLIT_RE = re.compile("(\n|\r(?![\n" + _LINE_JOIN + "])|\u2028|\u2029)") + +# Номер в/ч «А1234»; група 1 — роздільник між літерою і цифрами +_UNIT_RE = re.compile(r"\b[А-ЯA-Z]([ \xa0-]?)\d{4}\b") +_UNIT_CONTEXT_RE = re.compile(r"(?:\bв/ч|\bчастин\w*)\.?\s*$", re.IGNORECASE) + _ORDER_CONTEXT = re.compile( r"(?:наказ|розпорядженн|розпорядж|директив)\w*(?:[^.;\n№]|(?<=\d)\.(?=\d)){0,80}$", re.IGNORECASE) @@ -622,6 +646,12 @@ def _mask_text_context_aware_impl(text: str, masking_dict: Dict, instance_counte return text +# Роздільник слів ПІБ у рядку: пробіли, «|» і «;» таблиць, а також клітинки +# CSV — кома впритул до наступного слова («Петренко,Іван,Петрович») і лапки +# навколо клітинок («"Петренко";"Іван"»); до 3.1.22 такі ПІБ лишались відкритими +_SEPARATOR = r"((?:[\s|;\"]|,(?=[^\s,]))+)" + + def locate_words(line: str, phrase: str) -> Optional[Tuple[int, int, List[str]]]: """Знаходить слова *phrase* (розділені пробілом) у *line*, де між ними може стояти будь-який роздільник: кілька пробілів, таб, NBSP, «|» @@ -630,8 +660,7 @@ def locate_words(line: str, phrase: str) -> Optional[Tuple[int, int, List[str]]] words = phrase.split() if not words: return None - pattern = r"(? Optional[Tuple[i words = phrase.split() if not words: return None - pattern = r"(? Optional[Tuple[i return best.start(), best.end(), list(best.groups()) -def _words_pattern(phrase: str) -> str: - return (r"(? Pattern[str]: + """Скомпільований шаблон слів *phrase* з будь-якими роздільниками між + ними. Кеш: той самий ПІБ шукається в документі багато разів, а + вбудований кеш re (512 шаблонів) на великих документах витісняється.""" + return re.compile(WORD_BEFORE + _SEPARATOR.join(re.escape(w) for w in phrase.split()) + WORD_AFTER) _RANK_PIB_GAP_RE = re.compile(r"[\s|;«»\"„“”']*") @@ -676,8 +707,8 @@ def locate_rank_and_pib(line: str, rank: str, pib: str) -> Tuple[Optional[Tuple[ перше входження ПІБ). До 3.1.20 включно ПІБ брався за першим входженням у рядку: у «М.О. Ткачук Адмірал Ткачук» звання діставалось першому «Ткачук».""" if rank and rank.split() and pib.split(): - rank_matches = list(re.finditer(_words_pattern(rank), line)) - for m in re.finditer(_words_pattern(pib), line): + rank_matches = list(_phrase_re(rank).finditer(line)) + for m in _phrase_re(pib).finditer(line): for r in reversed(rank_matches): if r.end() <= m.start(): if _RANK_PIB_GAP_RE.fullmatch(line[r.end():m.start()]): @@ -691,7 +722,7 @@ def _locate_from(line: str, phrase: str, pos: int) -> Optional[Tuple[int, int, L """Перше входження *phrase*, що починається не раніше *pos*.""" if not phrase.split(): return None - m = re.compile(_words_pattern(phrase)).search(line, pos) + m = _phrase_re(phrase).search(line, pos) return (m.start(), m.end(), list(m.groups())) if m else None @@ -778,7 +809,7 @@ def _replace(m: "re.Match[str]") -> str: return word return mask_surname(word, masking_dict, instance_counters) - return re.sub(r"(? None: if result and _number_sign_enabled(text, match.start(), result['type']) \ and not _overlaps_mask(result['start'], result['end']): _add_mask(result) + if result['type'] == 'order_simple': + _add_listed_numbers(result['end']) + + def _add_listed_numbers(pos: int) -> None: + # «накази № 12, 13 та 14» — і решта номерів переліку (до 3.1.22 + # маскувався лише перший); «№ 12, 13 травня» — не перелік + while True: + m = _LISTED_NUMBER_RE.match(text, pos) + if not m: + return + group = 1 if m.group(1) is not None else 2 + s, e = m.start(group), m.end(group) + if _overlaps_mask(s, e) or _inside_skip(s, e): + return + _add_mask({'type': 'order_simple', 'full_text': m.group(group), 'number_part': m.group(group), + 'start': s, 'end': e}) + pos = e def _phase_br_number() -> None: if not _cfg.MASK_BR_NUMBERS: return - for match in re.finditer(r'\bБР\b', text, re.IGNORECASE): + for match in re.finditer(r'\bБР(?![^\W\d_])', text, re.IGNORECASE): result = analyze_br_keyword(text, match) if result: skip = _overlaps_skip(result['start'], result['end']) or _overlaps_mask(result['start'], result['end']) @@ -975,6 +1023,19 @@ def run() -> None: if not skip: _add_mask({'type': item_type, 'full_text': match.group(0), 'number_part': match.group(0), 'start': match.start(), 'end': match.end()}) return run + def _phase_military_unit() -> None: + # «А1234» — завжди; з пробілом, нерозривним пробілом чи дефісом + # («в/ч А 1234», «в/ч А\xa01234», «А-1234») — лише після «в/ч» / + # «частина»: до 3.1.22 «А 1234» з пробілом бралось без контексту, і + # «У 2025 році», «В 2024 р.», «А 2000 грн» маскувались як в/ч + if not _cfg.MASK_UNITS: + return + for match in _UNIT_RE.finditer(text): + if match.group(1) and not _UNIT_CONTEXT_RE.search(text[max(0, match.start() - 40):match.start()]): + continue + skip = _inside_skip(match.start(), match.end()) or _overlaps_mask(match.start(), match.end()) + if not skip: _add_mask({'type': 'military_unit', 'full_text': match.group(0), 'number_part': match.group(0), 'start': match.start(), 'end': match.end()}) + def _phase_brigade_number() -> None: if not _cfg.MASK_BRIGADES: return @@ -983,7 +1044,7 @@ def _phase_brigade_number() -> None: if not skip: _add_mask({'type': 'brigade_number', 'full_text': match.group(0), 'number_part': match.group(1), 'start': match.start(), 'end': match.end()}) def _legal_act_date(start: int) -> bool: - window = text[max(0, start - 160):start] + window = text[max(0, start - 500):start] # Захищені фрази (exclusions.phrases, custom skip) тут — токени # ___KEEP…___; для пошуку назви акта повертаємо оригінал for token, original in kept or (): @@ -1026,9 +1087,15 @@ def _phase_date_text() -> None: 'passport_id': _simple_phase('passport_id', 'MASK_PASSPORT', r'(? None: if line_join: text = _join_broken_pib_lines(text) - lines = text.split('\n') + # Рядки — і за одиночним CR (старі файли Mac) чи U+2028/U+2029: до 3.1.22 + # такий файл розбирався як один рядок — у десятки разів повільніше + parts = _LINE_SPLIT_RE.split(text) + lines, line_breaks = parts[0::2], parts[1::2] masked_lines = [] for line in lines: if not looks_like_pib_line(line): @@ -1217,7 +1287,7 @@ def _replace_span(line: str, span: Tuple[int, int, List[str]], token: str) -> st final_line = final_line.replace(token, values[token], 1) masked_lines.append(final_line) - text = '\n'.join(masked_lines) + text = ''.join(line + brk for line, brk in zip(masked_lines, line_breaks + [''])) # Прізвища, вже замасковані в документі, — і там, де стоять самі text = _mask_known_surnames_standalone(text, masking_dict, instance_counters) diff --git a/datamasking/masking/exclusions.py b/datamasking/masking/exclusions.py index a50e384..2a40716 100644 --- a/datamasking/masking/exclusions.py +++ b/datamasking/masking/exclusions.py @@ -145,10 +145,23 @@ def phrase_regex(phrase: str) -> str: return r"\s+".join(parts) +# Похідні «закон*», що не є назвою акта: «законодавства», «законних», +# «закономірно» (до 3.1.22 дата після них лишалась відкритою) +_NOT_ACT = r"(?!законодав|законн|закономір)" +# Між назвою акта і «від»: слова без коми й крапки, назва в лапках будь-якої +# довжини («Закону України «Про соціальний і правовий захист +# військовослужбовців та членів їх сімей» від …»), перенос рядка. До 3.1.22 — +# будь-що до 80 символів у межах рядка: «на підставі закону, рапорт від +# 01.02.2025» лишав дату рапорту відкритою, а дата закону з довгою назвою +# чи «від» на новому рядку зсувалась +_ACT_GAP = r"(?:«[^»]{0,300}»|[^.;,«»]){0,120}?" + + def legal_act_regex(acts: Iterable[str]) -> Pattern[str]: - """Дата одразу після «<акт> … від» (у межах речення, до 80 символів).""" + """Дата одразу після «<акт> … від» (див. _ACT_GAP).""" alternatives = "|".join(phrase_regex(a) for a in acts) - return re.compile(r"(?:" + alternatives + r")[^\n.;]{0,80}?\bвід\s*$", re.IGNORECASE) + return re.compile(r"(? str: _PUNCT = ',.!?;:' +_CLEAN_WORD = str.maketrans({**{a: "'" for a in _cfg.APOSTROPHE_CHARS}, + **{c: None for c in _cfg.INVISIBLE_CHARS}}) + + +def clean_word(word: str) -> str: + """Слово без невидимих символів і з ASCII-апострофом — для розпізнавання + й синтезу масок (у mapping лишається написання з документа).""" + return word.translate(_CLEAN_WORD) + def strip_word(word: str, leading_punct: bool = False) -> str: - """Слово без обрамлення (лапки, дужки, «/…/», «*…*») і розділових знаків - у кінці — у будь-якому поєднанні («Петровича),»); з *leading_punct* — - і розділових знаків на початку.""" - lead = _cfg.WRAP_CHARS + (_PUNCT if leading_punct else "") - trail = _cfg.WRAP_CHARS + _PUNCT + """Слово без обрамлення (лапки, дужки, «/…/», «*…*»), невидимих символів + по краях і розділових знаків у кінці — у будь-якому поєднанні + («Петровича),»); з *leading_punct* — і розділових знаків на початку.""" + lead = _cfg.WRAP_CHARS + _cfg.INVISIBLE_CHARS + (_PUNCT if leading_punct else "") + trail = _cfg.WRAP_CHARS + _cfg.INVISIBLE_CHARS + _PUNCT start, end = 0, len(word) while start < end and word[start] in lead: start += 1 diff --git a/datamasking/masking/mask_military.py b/datamasking/masking/mask_military.py index 6b58fc7..ecc287d 100644 --- a/datamasking/masking/mask_military.py +++ b/datamasking/masking/mask_military.py @@ -28,7 +28,7 @@ def mask_military_unit(original: str, masking_dict: Dict, instance_counters: Dic if original in masking_dict["mappings"]["military_unit"]: masked = masking_dict["mappings"]["military_unit"][original]["masked_as"] else: - match = re.match(r'^([А-ЯA-Z])( ?)(\d{4})$', original) + match = re.match(r'^([А-ЯA-Z])([ \xa0-]?)(\d{4})$', original) if not match: return original letter, sep = match.group(1), match.group(2) @@ -51,22 +51,37 @@ def _redigit(original: str, attempt: int = 0) -> str: return re.sub(r'\d', lambda _: str(random.randint(0, 9)), original) -def _distinct_mask(original: str, generate: "Callable[[int], str]", attempts: int = 10) -> str: +def _distinct_mask(original: str, generate: "Callable[[int], str]", attempts: int = 10, + widen: "Optional[Callable[[int], str]]" = None) -> str: """Маска, що не дорівнює оригіналу і не збігається з числом/словом документа (інакше unmask мінял би їх місцями, v3.1.9). *generate(attempt)* - — детермінований генератор; після *attempts* спроб — остання маска. + — детермінований генератор; *widen(attempt)* — запасний генератор + довших масок, коли всі короткі зайняті; інакше — остання маска. Потрібна кожному генератору, що міняє лише частину цифр: до 3.1.21 військовий квиток (змінюються 2 цифри з 6) маскувався сам у себе в ~1 % випадків, «№5дск» — у кожному десятому, і номер лишався відкритим.""" - masked = generate(0) - for attempt in range(1, attempts + 1): - if masked != original and not _surname.document_contains(masked): - break - masked = generate(attempt) + masked = original + for gen in (generate, widen): + if gen is None: + continue + for attempt in range(attempts + 1): + masked = gen(attempt) + if masked != original and not _surname.document_contains(masked): + return masked return masked +def _widened(original: str) -> "Callable[[int], str]": + """Генератор масок на одну цифру довших: однозначний номер «№ 5» у + документі з відкритими пунктами 1–9 інакше діставав маску, що збігалась + із номером пункту, і unmask міняв їх місцями (до 3.1.22 — у ~30 % + однозначних номерів таких документів; остання спроба взагалі не + перевірялась і могла дорівнювати оригіналу).""" + longer = re.sub(r"\d+", lambda m: m.group(0) + "0", original, count=1) + return lambda attempt: _redigit(longer, attempt) + + def mask_order_number(original: str, masking_dict: Dict, instance_counters: Dict) -> str: """ Маскує номер наказу. @@ -76,14 +91,14 @@ def mask_order_number(original: str, masking_dict: Dict, instance_counters: Dict if original in masking_dict["mappings"]["order_number"]: masked = masking_dict["mappings"]["order_number"][original]["masked_as"] else: - masked = _distinct_mask(original, lambda a: _redigit(original, a)) + masked = _distinct_mask(original, lambda a: _redigit(original, a), widen=_widened(original)) return add_to_mapping(masking_dict, instance_counters, "order_number", original, masked) def mask_order_number_with_letters(original: str, masking_dict: Dict, instance_counters: Dict) -> str: if original in masking_dict["mappings"]["order_number_with_letters"]: masked = masking_dict["mappings"]["order_number_with_letters"][original]["masked_as"] else: - masked = _distinct_mask(original, lambda a: _redigit(original, a)) + masked = _distinct_mask(original, lambda a: _redigit(original, a), widen=_widened(original)) return add_to_mapping(masking_dict, instance_counters, "order_number_with_letters", original, masked) def mask_br_number(original: str, masking_dict: Dict, instance_counters: Dict) -> str: @@ -103,7 +118,7 @@ def mask_br_number(original: str, masking_dict: Dict, instance_counters: Dict) - # тож у «БР 123/45» перший сегмент, а в «БР 566» усе лишалось відкритим) prefix = "" number_part = original - match = re.match(r'(№\s*)?(БР[-\s]?)?', original, re.IGNORECASE) + match = re.match(r'(№\s*)?(БР(?:\s*:\s*|[-\s])?)?', original, re.IGNORECASE) if match and match.group(0): prefix = match.group(0) number_part = original[len(prefix):] @@ -282,7 +297,12 @@ def mask_date_text(original: str, masking_dict: Dict, instance_counters: Dict) - # Зміщуємо день на +-5 (в межах 1-28) day_shift = random.choice([-5, -3, -2, 2, 3, 5]) - new_day = str(max(1, min(28, int(day) + day_shift))).zfill(len(day)) + # За межами 1–28 — зсув у інший бік: до 3.1.22 обрізання давало той + # самий день («1» − 5 → 1, «28» + 3 → 28) + shifted = int(day) + day_shift + if not 1 <= shifted <= 28: + shifted = int(day) - day_shift + new_day = str(max(1, min(28, shifted))).zfill(len(day)) # Замінюємо місяць на випадковий інший available_months = [m for m in _cfg._MONTHS_UA_LIST if m != month_name.lower()] diff --git a/datamasking/masking/mask_personal.py b/datamasking/masking/mask_personal.py index 36fa34b..29d8cb4 100644 --- a/datamasking/masking/mask_personal.py +++ b/datamasking/masking/mask_personal.py @@ -9,11 +9,11 @@ import random import re -from typing import Dict, List, Optional +from typing import Dict, List, Optional, Set from datamasking.masking import constants as _cfg from datamasking.masking.helpers import ( - add_to_mapping, get_deterministic_seed, record_instance, + add_to_mapping, clean_word, get_deterministic_seed, record_instance, _apply_original_case, normalize_identifier, ) from datamasking.masking.declension import ( @@ -126,21 +126,33 @@ def mask_surname(original: str, masking_dict: Dict, instance_counters: Dict) -> masked = masking_dict["mappings"]["surname"][original]["masked_as"] else: forbidden = known_surname_forms(masking_dict, except_original=original) - hyphen_parts = original.split('-') + # Маска — від слова без невидимих символів і з ASCII-апострофом; ключ + # mapping — написання з документа (unmask відновлює його точно) + base = clean_word(original) + hyphen_parts = base.split('-') if len(hyphen_parts) == 2 and all(len(p) >= 3 for p in hyphen_parts): # Подвійне прізвище: кожна частина — власна синтетична маска, # структура «Х-Y» зберігається (Петренко-Іванова → Сірченко-Юхимова) + # Ціле подвійне прізвище теж не має збігатися з іншою маской: + # «Дороше\u00adнка-Ґудзя» і «Дорошенка-Ґудзя» інакше діставали одну + # маску, і unmask плутав, яке з написань де стояло masked_parts: List[str] = [] - for part in hyphen_parts: - mp = synthesize_surname(part, forbidden=forbidden | set(masked_parts)) - masked_parts.append(_apply_original_case(part, mp)) + taken: Set[str] = set() + for _attempt in range(8): + masked_parts = [] + for part in hyphen_parts: + mp = synthesize_surname(part, forbidden=forbidden | taken | {m.lower() for m in masked_parts}) + masked_parts.append(_apply_original_case(part, mp)) + if '-'.join(masked_parts).lower() not in forbidden: + break + taken.add(masked_parts[-1].lower()) masked = '-'.join(masked_parts) else: # Інша форма того самого прізвища вже має маску — її основа # (див. surname.paradigm_mask), інакше синтез masked = _apply_original_case( - original, paradigm_mask(original, masking_dict, forbidden) - or synthesize_surname(original, forbidden=forbidden)) + base, paradigm_mask(base, masking_dict, forbidden) + or synthesize_surname(base, forbidden=forbidden)) return add_to_mapping(masking_dict, instance_counters, "surname", original, masked) def pseudo_gender(value: str) -> str: diff --git a/datamasking/masking/surname.py b/datamasking/masking/surname.py index 68b32f2..dd97e30 100644 --- a/datamasking/masking/surname.py +++ b/datamasking/masking/surname.py @@ -25,12 +25,13 @@ одну синтетичну основу (МАЗУРЕНКА / Мазуренко / Мазуренку). """ +import functools import random import re from typing import Dict, Iterable, Optional, Set, Tuple from datamasking.masking import constants as _cfg -from datamasking.masking.helpers import get_deterministic_seed +from datamasking.masking.helpers import clean_word, get_deterministic_seed # Закінчення → словотвірна родина. Порядок: довші першими (перший збіг). # Основа після відрізання має бути ≥ MIN_STEM літер, інакше закінчення @@ -65,7 +66,11 @@ ("унові", "ун"), ("уном", "ун"), ("уна", "ун"), ("уну", "ун"), ("ун", "ун"), # -ко (Сірко, Бойко) — після -енко, щоб не перехопити ("кові", "ко"), ("ком", "ко"), ("ка", "ко"), ("ку", "ко"), ("ко", "ко"), - # прикметникові -ий / -ій (Білий, Заболотній) + # прикметникові -ий / -ій (Білий, Заболотній); м'яка відміна — + # «-нього», «-ньому», «-нім», «-ньої», «-ньою» (до 3.1.22 «Задорожнього» + # мало основу «задорожнь», а «Задорожнім» не розпізнавалось зовсім: + # три відмінки — три різні синтетичні основи) + ("ього", "ий"), ("ьому", "ий"), ("ьої", "я"), ("ьою", "я"), ("ім", "ий"), ("ого", "ий"), ("ому", "ий"), ("им", "ий"), ("ий", "ий"), ("ій", "ий"), # жіночий родовий прикметникових (Білої, Довгої): до 3.1.20 включно не # розпізнавався, і маска «Білої» мала іншу основу, ніж «Біла» / «Білій» @@ -87,7 +92,7 @@ # реєструє словник на час обробки документа; після виходу він очищується, # щоб маски лишались детермінованими між документами. # --------------------------------------------------------------------------- -_WORD_RE = re.compile(r"[А-ЯІЇЄҐа-яіїєґ][А-ЯІЇЄҐа-яіїєґ'’]+") +_WORD_RE = re.compile(r"[А-ЯІЇЄҐа-яіїєґ][А-ЯІЇЄҐа-яіїєґ'’ʼ" + _cfg.JOINER_CHARS + r"]+") _document_vocab: Set[str] = set() _document_long_words: Set[str] = set() _document_text: str = "" @@ -102,7 +107,7 @@ def enter_document(text: str) -> None: """Реєструє слова документа (зовнішній виклик); вкладені — лише лічильник.""" global _depth, _document_vocab, _document_long_words, _document_text if _depth == 0: - words = {m.group(0).lower() for m in _WORD_RE.finditer(text)} + words = {clean_word(m.group(0)).lower() for m in _WORD_RE.finditer(text)} _document_vocab = words _document_long_words = {w for w in words if len(w) >= 5} _document_text = text @@ -130,9 +135,15 @@ def document_contains(token: str) -> bool: return False if _residue_tokens is not None and " " not in token: return token.lower() in _residue_tokens - before = r"(? "re.Pattern[str]": + join = _cfg.JOINER_CHARS + before = r"(? None: @@ -155,9 +166,12 @@ def split_surname(word: str) -> Tuple[str, str, str]: Якщо жодне закінчення не підходить (Ґудзь, Коваль, Шамрай) — закінчення порожнє, основа = усе слово, родина "". """ - w = word.lower() + w = clean_word(word).lower() for ending, family in _ENDINGS: if w.endswith(ending) and len(w) - len(ending) >= MIN_STEM: + # «-ім» — лише м'яка відміна після «н» (Задорожнім), не «Акім» + if ending == "ім" and not w[:-2].endswith("н"): + continue return w[: -len(ending)], ending, family return w, "", "" @@ -314,7 +328,10 @@ def _leaks(masked: str, original: str, stem: str) -> bool: # кожна форма діставала свою синтетичну основу: Коваль → Ковар, але # Коваля → Кобіля (до 3.1.22). У межах документа форма бере основу з маски # іншої форми того самого прізвища, якщо та вже є -_PLAIN_FAMILIES = frozenset({"", "а", "я"}) +# «ий» — прикметникові: «Головата» / «Головатій» (жіночий давальний +# розбирається як «-ій» родини «ий») мають ділити основу маски; до 3.1.22 +# у них різнився навіть префікс («Голудна» / «Горудній») +_PLAIN_FAMILIES = frozenset({"", "а", "я", "ий"}) def _plain_paradigm(word: str) -> Optional[Tuple[str, str]]: @@ -335,9 +352,18 @@ def _plain_paradigm(word: str) -> Optional[Tuple[str, str]]: _HUSHING = frozenset("жчшщ") _TO_HARD = {"я": "а", "ю": "у", "ем": "ом", "єм": "ом", "еві": "ові"} _TO_HUSHING = {"я": "а", "ю": "у", "ом": "ем", "єм": "ем", "ові": "еві"} +# М'які прикметникові закінчення — лише після «н» нової основи, інакше +# тверді («задашт» + «ього» → «Задаштого», не «Задаштього») +_SOFT_TO_HARD = {"ього": "ого", "ьому": "ому", "ім": "им", "ьої": "ої", "ьою": "ою"} + + +def _soft_ending(ending: str, stem: str) -> str: + return ending if stem.endswith("н") else _SOFT_TO_HARD.get(ending, ending) def _harmonize(ending: str, stem: str) -> str: + if ending in _SOFT_TO_HARD: + return _soft_ending(ending, stem) last = stem[-1:] if last in _HARD_ONLY: return _TO_HARD.get(ending, ending) @@ -350,12 +376,13 @@ def paradigm_mask(original: str, masking_dict: Dict, forbidden: Set[str]) -> Opt """Маска (нижній регістр) для *original* з основою маски іншої форми того самого прізвища в цьому документі — або None, якщо такої форми немає чи похідна маска не годиться (витік, збіг зі словом документа).""" + original = clean_word(original) own = _plain_paradigm(original) if own is None: return None stem, ending = own for other, info in masking_dict.get("mappings", {}).get("surname", {}).items(): - if not isinstance(info, dict) or not info.get("masked_as") or other.lower() == original.lower(): + if not isinstance(info, dict) or not info.get("masked_as") or clean_word(other).lower() == original.lower(): continue theirs = _plain_paradigm(other) if theirs is None or theirs[0] != stem: @@ -402,6 +429,9 @@ def synthesize_surname(original: str, forbidden: Optional[Set[str]] = None, відмінкове/родове закінчення; не містить оригінал і не збігається з жодним рядком із *forbidden*. """ + # Невидимі символи й написання апострофа («Дем‘янчук», «Пет\u00adренко») + # не впливають на маску: усі написання дають ту саму основу + original = clean_word(original) forbidden = {f.lower() for f in (forbidden or set())} | _document_vocab stem, ending, family = split_surname(original) # Seed — від основи в нижньому регістрі, а не від поверхневої форми: @@ -417,7 +447,7 @@ def synthesize_surname(original: str, forbidden: Optional[Set[str]] = None, new_stem = _pick_stem(seed, len(stem), family, ending, forbidden={stem}, prefix=prefix, consonant_end=(not ending and not family and (stem[-1:] in _CONSONANTS or stem.endswith("ь")))) - masked = new_stem + ending + masked = new_stem + _soft_ending(ending, new_stem) last = masked if not _leaks(masked, original, stem) and masked not in forbidden: return masked diff --git a/datamasking/unmasking/cli.py b/datamasking/unmasking/cli.py index 38a05b8..5321aab 100644 --- a/datamasking/unmasking/cli.py +++ b/datamasking/unmasking/cli.py @@ -61,7 +61,7 @@ # ============================================================================ from datamasking._version import __version__ # єдине джерело версії from datamasking._textio import read_text, check_encoding_settings -from datamasking._fsutil import atomic_write_private +from datamasking._fsutil import atomic_write_private, same_file import random @@ -126,6 +126,15 @@ def main(argv=None) -> int: """ Головна функція CLI для unmask. Повертає код виходу (0 — успіх, 1 — помилка). """ + try: + return _main(argv) + except RecursionError: + # Надто глибоко вкладений JSON (до 3.1.22 — traceback) + print("❌ The data is nested too deeply to process", file=sys.stderr) + return EXIT_ERROR + + +def _main(argv=None) -> int: # Fix Unicode output on Windows (PyInstaller cp1252 issue) if sys.platform == 'win32' and getattr(sys.stdout, 'encoding', 'utf-8').lower().replace('-', '') != 'utf8': import io @@ -265,6 +274,15 @@ def log_debug(msg): if getattr(log_cfg, 'log_to_file', None) is not False: log_file = getattr(log_cfg, 'file', None) console = getattr(log_cfg, 'log_to_console', True) is not False + # Лог не пишеться у вхідні файли чи результат: до 3.1.22 + # «--log-file map.json» дописував рядки логу в mapping (а .enc після + # цього вже не розшифровувався) + if enabled and log_file: + for label, other in (("masked file", args.masked_file), ("mapping", args.map_file), + ("output", args.output)): + if other and same_file(log_file, other): + print(f"❌ Log file {log_file} is the {label}", file=sys.stderr) + return EXIT_USAGE if enabled: try: logger = setup_logging(level=log_level, log_file=log_file, console=console) @@ -311,6 +329,15 @@ def log_debug(msg): return EXIT_ERROR masked_path, map_path, output_path = result log_info(f"Автоматичний режим: {masked_path.name}") + # --map і -o діють і тут (до 3.1.22 мовчки ігнорувались) + if args.map_file: + map_path = Path(args.map_file) + if not map_path.exists(): + print(f"❌ Файл маппінгу не знайдено: {map_path}") + log_error(f"Файл маппінгу не знайдено: {map_path}") + return EXIT_ERROR + if args.output: + output_path = Path(args.output) else: masked_path = Path(args.masked_file) if not masked_path.exists(): @@ -359,19 +386,23 @@ def log_debug(msg): if same_as: print(f"❌ Вихідний файл збігається з {same_as[0]}") return EXIT_USAGE - if output_path.exists() and not getattr(args, 'force', False): - print(f"❌ Файл {output_path} уже існує (використайте --force для перезапису)") - return EXIT_USAGE if output_path.is_dir(): print(f"❌ {output_path} — каталог") return EXIT_USAGE + if output_path.exists() and not getattr(args, 'force', False): + print(f"❌ Файл {output_path} уже існує (використайте --force для перезапису)") + return EXIT_USAGE # ======================================================================== # ЗАВАНТАЖЕННЯ ФАЙЛІВ # ======================================================================== try: - masking_map = load_mapping_file(map_path, password=password) + # Mapping більший за документ (≈2,5×, ланцюг --re-mask — до ~30×): до + # 3.1.22 ліміт був сталі 100 МБ, і unmask не читав mapping, який mask + # щойно записав + masking_map = load_mapping_file(map_path, password=password, + max_size=30 * _size_limit(config)) validate_mapping_schema(masking_map) log_info(f"Mapping завантажено: {map_path.name}") diff --git a/datamasking/unmasking/engine.py b/datamasking/unmasking/engine.py index 30e8cad..78086cf 100644 --- a/datamasking/unmasking/engine.py +++ b/datamasking/unmasking/engine.py @@ -225,9 +225,11 @@ def unmask_ranks_gender_aware(masked_text: str, masking_map: Dict) -> Tuple[str, return restored_text, stats -_DT_QUOTE_OPEN = r'[«"„“\']?\s*' -_DT_QUOTE_CLOSE = r'\s*[»"“”\']?' -_DT_STRIP_RE = re.compile(r'[«»"„“”\']') +# Пробіли — лише після лапки (інакше квадратичний час на довгих пробілах); +# між днем і місяцем пробілу може не бути («28»вересня — так маскується) +_DT_QUOTE_OPEN = r'(?:[«"„“\'‘]\s*)?' +_DT_QUOTE_CLOSE = r'\s*(?:[»"“”\'’]\s*)?' +_DT_STRIP_RE = re.compile(r'[«»"„“”\'‘’]') def _restore_quoted_date(matched: str, masked_key: str, original_key: str) -> str: @@ -307,7 +309,7 @@ def _alternative(mask: str) -> str: if mask in date_text_masks: parts = mask.split() if len(parts) == 3 and parts[0].isdigit(): - return (_DT_QUOTE_OPEN + re.escape(parts[0]) + _DT_QUOTE_CLOSE + r'\s+' + return (_DT_QUOTE_OPEN + re.escape(parts[0]) + _DT_QUOTE_CLOSE + re.escape(parts[1]) + r'\s+' + re.escape(parts[2])) return re.escape(mask) @@ -354,7 +356,7 @@ def _bounded(mask: str) -> str: if not inst and date_text_masks: # «28» вересня 2025 → ключ «28 вересня 2025» key = _DT_STRIP_RE.sub('', matched).lower() - key = ' '.join(key.split()) + key = ' '.join(re.sub(r'(\d)(?=[^\W\d_])', r'\1 ', key).split()) inst, counter_key = _lookup(key, key) quoted_date = inst is not None if not inst: diff --git a/datamasking/unmasking/helpers.py b/datamasking/unmasking/helpers.py index 8f8dc46..52a4a4e 100644 --- a/datamasking/unmasking/helpers.py +++ b/datamasking/unmasking/helpers.py @@ -202,6 +202,24 @@ def build_instance_map(masking_map: Dict) -> Dict[str, Dict[int, str]]: return instance_map +def _first_letter(word: str) -> str: + return next((ch for ch in word if ch.isalpha()), "") + + +def _is_title(word: str) -> bool: + """Перша літера велика, решта малі (невидимі символи й апостроф перед + першою літерою не рахуються: «\u00adҐудзем»).""" + letters = [ch for ch in word if ch.isalpha()] + return len(letters) > 1 and letters[0].isupper() and "".join(letters[1:]).islower() + + +def _capitalize(word: str) -> str: + """str.capitalize(), що пропускає невидимі символи перед першою літерою + (до 3.1.22 «\u00adҐудзем» ставало «\u00adґудзем»).""" + i = next((k for k, ch in enumerate(word) if ch.isalpha()), 0) + return word[:i] + word[i:i + 1].upper() + word[i + 1:].lower() + + def _apply_original_case(original: str, masked: str) -> str: """ Відновлює регістр літер оригіналу на маскованому значенні. @@ -211,14 +229,14 @@ def _apply_original_case(original: str, masked: str) -> str: if original.isupper(): return masked.upper() - elif len(original) > 1 and original[0].isupper() and original[1:].islower(): + elif _is_title(original): # Подвійне ім'я («Анна-Марія», «АННА-МАРІЯ»): кожна частина з великої, # якщо так записано в mapping — до 3.1.22 capitalize() давав # «Анна-марія». «генерал-майор» лишається «Генерал-майор» parts = masked.split("-") - if len(parts) > 1 and all(p[:1].isupper() for p in parts): - return "-".join(p.capitalize() for p in parts) - return masked.capitalize() + if len(parts) > 1 and all(_first_letter(p).isupper() for p in parts): + return "-".join(_capitalize(p) for p in parts) + return _capitalize(masked) elif original.islower(): return masked.lower() # Подвійне слово (маска «Доличенко-Ґубко»): регістр — по частинах, з diff --git a/datamasking/unmasking/io.py b/datamasking/unmasking/io.py index a1a9dc5..90cb892 100644 --- a/datamasking/unmasking/io.py +++ b/datamasking/unmasking/io.py @@ -13,7 +13,7 @@ from pathlib import Path from typing import Any, Dict, Optional, Union -from datamasking.unmasking.helpers import validate_file_size +from datamasking.unmasking.helpers import MAX_INPUT_FILE_SIZE, validate_file_size from datamasking.unmasking.engine import is_chain_mapping # Optional modules @@ -32,7 +32,8 @@ pass -def load_mapping_file(map_path: Path, password: Optional[str] = None) -> Dict: +def load_mapping_file(map_path: Path, password: Optional[str] = None, + max_size: Optional[int] = None) -> Dict: """ Завантажує mapping файл з підтримкою шифрування. @@ -63,13 +64,14 @@ def load_mapping_file(map_path: Path, password: Optional[str] = None) -> Dict: "Вкажіть --password / --password-env або встановіть DATA_MASKING_PASSWORD" ) - validate_file_size(map_path) + validate_file_size(map_path, max_size or MAX_INPUT_FILE_SIZE) # v2.5–3.0.0: викликалось MappingSecurityManager(password) — клас без # __init__ → TypeError на кожному .enc; шифровані mapping не читались return MappingSecurityManager().decrypt_mapping(map_path, password) else: - validate_file_size(map_path) - with open(map_path, 'r', encoding='utf-8') as f: + validate_file_size(map_path, max_size or MAX_INPUT_FILE_SIZE) + # utf-8-sig: mapping, збережений редактором з BOM, теж читається + with open(map_path, 'r', encoding='utf-8-sig') as f: data = json.load(f) if not isinstance(data, dict): raise ValueError("Mapping file must be a JSON object") @@ -99,9 +101,23 @@ def validate_mapping_schema(mapping: Dict) -> None: _major = re.match(r"v?(\d+)\.", str(version)) if version else None if _major and int(_major.group(1)) >= 2 and "mappings" not in mapping: raise ValueError(f"Mapping v{version} must contain 'mappings' key") + # Будь-який інший JSON (config.json, сам замаскований output.json, {}) + # до 3.1.22 приймався, і unmask «успішно» нічого не відновлював + if "mappings" not in mapping and "re_mask_passes" not in mapping: + raise ValueError("Not a mapping file: no 'mappings' key") + if "re_mask_passes" in mapping: + passes = mapping["re_mask_passes"] + if not isinstance(passes, list) or not all(isinstance(p, dict) for p in passes): + raise ValueError("'re_mask_passes' must be a list of objects") + for i, pass_map in enumerate(passes, 1): + _validate_mappings(pass_map, f"re_mask_passes[{i}].") _validate_mappings(mapping, "") +def _is_mask(value: str) -> bool: + return any(ch.isalnum() for ch in value) + + def _validate_mappings(mapping: Dict, where: str) -> None: """Кожна категорія — об'єкт; запис — рядок (v1) або об'єкт із masked_as (рядок) та instances (список цілих). До 3.1.10 битий mapping @@ -117,14 +133,18 @@ def _validate_mappings(mapping: Dict, where: str) -> None: for original, info in entries.items(): # Порожня маска «знаходиться» між будь-якими двома символами, і # unmask вставляв оригінал у випадкові місця (до 3.1.21) + # Маска без жодної літери чи цифри (« », «.») теж: unmask вставляв + # оригінали між словами (до 3.1.22) if isinstance(info, str): - if not info: - raise ValueError(f"'{where}mappings.{category}.{original}' must not be empty") + if not _is_mask(info): + raise ValueError(f"'{where}mappings.{category}.{original}' must not be empty " + f"or only spaces/punctuation") continue if not isinstance(info, dict): raise ValueError(f"'{where}mappings.{category}.{original}' must be an object or a string") - if not isinstance(info.get("masked_as"), str) or not info["masked_as"]: - raise ValueError(f"'{where}mappings.{category}.{original}.masked_as' must be a non-empty string") + if not isinstance(info.get("masked_as"), str) or not _is_mask(info["masked_as"]): + raise ValueError(f"'{where}mappings.{category}.{original}.masked_as' must be a string, " + f"not empty or only spaces/punctuation") instances = info.get("instances", []) if not isinstance(instances, list) or not all(isinstance(n, int) for n in instances): raise ValueError(f"'{where}mappings.{category}.{original}.instances' must be a list of integers") diff --git a/tests/test_fuzz_findings4.py b/tests/test_fuzz_findings4.py new file mode 100644 index 0000000..2b56ac2 --- /dev/null +++ b/tests/test_fuzz_findings4.py @@ -0,0 +1,317 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Знахідки четвертого пошуку помилок (після 3.1.21). + + - невидимий символ (м'який перенос, zero-width space, BOM посеред тексту) + чи апостроф «‘»/«`» усередині прізвища: фаза «прізвище + ініціали» + маскувала хвіст, основний розбір — усе слово ще раз, і unmask не + відновлював оригінал; різні написання діставали різні основи; + - прізвища на «-ній» (Задорожній / Задорожнього / Задорожнім) і + «Головата» / «Головатій» — різні синтетичні основи; + - маска однозначного номера наказу збігалась із відкритим номером пункту; + - дата після «законодавства», «законних», «закону, рапорт від» лишалась + відкритою; дата закону з довгою назвою чи «від» на новому рядку — зсувалась; + - «У 2025 році» маскувалось як номер в/ч; «в/ч А\\xa01234», «в/ч А-1234» — ні; + - паспорт «АА123456» з --only passport лишався відкритим; + - текстова дата: «'28' вересня», ««28»вересня» (без пробілу), той самий день; + - «№ 15.01.2025», «БР566», «БР: 566», «накази № 12, 13 та 14»; + - CSV через кому і в лапках; файли з самими CR; + - CLI: --log-file у вхідний файл чи mapping, сторонній JSON як mapping, + маска з пробілу, кодек base64, глибоко вкладений JSON, -o каталог. +""" +import json +import os +import re +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) + +from datamasking import _textio # noqa: E402 +from datamasking.masking import cli as mask_cli # noqa: E402 +from datamasking.masking import constants as _cfg # noqa: E402 +from datamasking.masking.helpers import clean_word # noqa: E402 +from datamasking.masking.mask_military import mask_date_text # noqa: E402 +from datamasking.masking.mask_personal import mask_surname # noqa: E402 +from datamasking.unmasking import cli as unmask_cli # noqa: E402 +from datamasking.unmasking.engine import unmask_text_v2 # noqa: E402 +from datamasking.unmasking.helpers import check_mapping_version # noqa: E402 +from datamasking.unmasking.io import validate_mapping_schema # noqa: E402 +from tests.test_initials import make_masking_dict, mask # noqa: E402 + +try: + import cryptography # noqa: F401 + _HAS_CRYPTO = True +except ImportError: + _HAS_CRYPTO = False + + +def _rt(text): + masked, mapping = mask(text) + restored, _ = unmask_text_v2(masked, mapping, check_mapping_version(mapping)) + assert restored == text, (masked, restored) + return masked, mapping + + +def _clean(text): + return clean_word(text).lower() + + +class TestInvisibleCharacters: + @pytest.mark.parametrize("text, secret", [ + ("капітан ЗІНЧ​ЕНКО Є.Ю.\n", "зінченко"), + ("Рапорт капітана медичної служби КРАВ­ЧЕНКО Г.С.\n", "кравченко"), + ("ЗІНЧ​ЕНКО Є.Ю. прибув\n", "зінченко"), + ("сержант ДЕМ‘ЯНЧУКУ Т.Р.\n", "дем'янчуку"), + ("майор П`ЯТНИЧУК В.Ю.\n", "п'ятничук"), + ("Разом з підполковником Дорошенком-­Ґудзем Іваном Івановичем.\n", "ґудзем"), + ("Разом з віце-адміралом Дорошенком-​Ґудзем Д.І.\n", "ґудзем"), + ("‌старший матрос Поліщук Богдан Ів­анович.\n", "іванович"), + ("​генерал-лейтенант Си​доров\xa0С.І.\n", "сидоров"), + ("майор Пет­ренко Іван Петрович\n", "петренко"), + ]) + def test_masked_and_restored(self, text, secret): + masked, _ = _rt(text) + assert secret not in _clean(masked), masked + + def test_bom_inside_text(self): + _rt("рапорт\nстарший матрос\xa0Петренко Наталія Сергіївна.\n" + "Разом з старшим матросом Петренко Н.С.\n") + + def test_two_spellings_in_one_document(self): + masked, mapping = _rt("Рапорт підполковника Дороше­нка-Ґудзя Віталія Юрійовича.\n" + "Рапорт підполковника Дорошенка-Ґудзя В.Ю.\n") + masks = {info["masked_as"] for info in mapping["mappings"]["surname"].values()} + assert len(masks) == 2 + + def test_apostrophe_spellings_share_stem(self): + d = make_masking_dict() + masks = [mask_surname(f, d, {}).lower() for f in ("Дем‘янчук", "Дем'янчука", "Демʼянчуку", "Дем`янчуком")] + assert len(os.path.commonprefix(masks)) >= 5, masks + + def test_mask_has_no_invisible_characters(self): + out = mask_surname("Пет­ренко", make_masking_dict(), {}) + assert not any(ch in out for ch in _cfg.INVISIBLE_CHARS) + + +class TestAdjectiveSurnameStems: + @pytest.mark.parametrize("forms", [ + ("Задорожній", "Задорожнього", "Задорожньому", "Задорожнім"), + ("Задорожнім", "Задорожньому", "Задорожнього", "Задорожній"), + ("Задорожня", "Задорожньої", "Задорожній", "Задорожньою"), + ("Головата", "Головатої", "Головатій", "Головатою"), + ("Головатій", "Головатої", "Головата"), + ]) + def test_one_stem(self, forms): + d = make_masking_dict() + masks = [mask_surname(f, d, {}).lower() for f in forms] + assert len(os.path.commonprefix(masks)) >= 5, masks + assert not any("ього" in m or "ьому" in m for m in masks if not m[:-4].endswith("н")) + + +class TestNumbers: + def test_single_digit_order_number(self): + text = "".join(f"{i}. Пункт.\n" for i in range(1, 10)) + "Підстава: наказ № 5.\n" + masked, _ = _rt(text) + assert "№ 5." not in masked + + def test_listed_order_numbers(self): + masked, _ = _rt("накази № 12, 13 та 14 скасувати.\n") + assert not re.search(r"\b(12|13|14)\b", masked), masked + + @pytest.mark.parametrize("text, kept", [ + ("наказ № 12 та 13 травня\n", "13 травня"), + ("наказ № 12 і 2025 року\n", "2025 року"), + ]) + def test_not_a_list(self, text, kept): + masked, _ = _rt(text) + assert kept in masked + + def test_date_after_number_sign(self): + masked, _ = _rt("наказ № 15.01.2025\n") + assert "01.2025" not in masked and re.search(r"№ \d{2}\.\d{2}\.\d{4}", masked) + + @pytest.mark.parametrize("text, number", [("БР566 видано\n", "566"), ("БР: 567 видано\n", "567"), + ("БР-568 видано\n", "568")]) + def test_br_forms(self, text, number): + masked, _ = _rt(text) + assert number not in masked + + def test_years_not_unit_numbers(self): + text = "У 2025 році прибув. В 2024 р. З 2023 року. А 2000 грн. О 1430 годині.\n" + assert mask(text)[0] == text + + @pytest.mark.parametrize("text", ["в/ч А\xa01234\n", "в/ч А-1234\n", "в/ч А 1234\n", + "військова частина А 1234\n", "військової частини В 1234\n", + "в/ч А1234\n", "наказ командира А1234\n"]) + def test_unit_numbers(self, text): + masked, _ = _rt(text) + assert "1234" not in masked + + def test_old_passport_with_only_passports(self, monkeypatch): + monkeypatch.setattr(_cfg, "MASK_MILITARY_ID", False) + masked, _ = _rt("паспорт АА123456, АА 654321\n") + assert "123456" not in masked and "654321" not in masked + + +class TestDates: + @pytest.mark.parametrize("text", [ + "Відповідно до законодавства звільнити від 01.02.2025\n", + "на підставі закону, рапорт від 01.02.2025\n", + "відповідно до законних вимог, рапорт від 01.02.2025\n", + ]) + def test_not_a_legal_act(self, text): + masked, _ = _rt(text) + assert "01.02.2025" not in masked + + @pytest.mark.parametrize("text, date", [ + ("Закону України «Про соціальний і правовий захист військовослужбовців та членів їх сімей» " + "від 20.12.1991\n", "20.12.1991"), + ("Закону України\nвід 06.12.1991\n", "06.12.1991"), + # номер закону маскується як номер (зберігати — через custom skip), дата — ні + ("Закону України № 2232-XII від 25.03.1992\n", "25.03.1992"), + ]) + def test_legal_act_date_kept(self, text, date): + assert date in _rt(text)[0] + + @pytest.mark.parametrize("text", ["«28»вересня 2025 року\n", "28вересня 2025\n", + "'28' вересня 2025\n", "‘28’ вересня 2025\n"]) + def test_text_date_forms(self, text): + masked, _ = _rt(text) + assert "вересня" not in masked + + @pytest.mark.parametrize("day", ["1", "01", "28", "27", "2"]) + def test_text_date_day_changes(self, day): + for month in ("січня", "березня", "травня", "вересня", "грудня"): + for year in ("2023", "2024", "2025"): + out = mask_date_text(f"{day} {month} {year}", make_masking_dict(), {}) + assert out.split()[0] != day, out + + def test_long_whitespace_is_fast(self): + import time + text = " " * 40000 + "28 вересня 2025\n" + "\n" * 20000 + start = time.time() + _rt(text) + assert time.time() - start < 10 + + +class TestCsv: + @pytest.mark.parametrize("text", [ + "Петренко,Іван,Петрович,сержант,1234567890\n", + "сержант,Петренко,Іван,Петрович\n", + '"Петренко";"Іван";"Петрович";"сержант"\n', + '"Петренко","Іван","Петрович"\n', + '"Петренко Іван Петрович";"сержант"\n', + ]) + def test_masked_and_separators_kept(self, text): + masked, _ = _rt(text) + for word in ("Петренк", "Іван", "Петрович"): + assert word not in masked, masked + for sep in ',;"': + assert masked.count(sep) == text.count(sep) + + def test_prose_comma_still_ends_name(self): + masked, _ = _rt("Петренко Іван Петрович, сержант\n") + assert ", сержант" in masked + + +class TestLineBreaks: + @pytest.mark.parametrize("nl", ["\r", "
"]) + def test_same_as_lf(self, nl): + text = ("солдат Петренко Іван Петрович\nсолдата Коваля Олега Петровича\n" + "Призначити Петренка\nІвана Івановича на посаду.\n") + masked_lf, _ = _rt(text) + masked, _ = _rt(text.replace("\n", nl)) + assert masked == masked_lf.replace("\n", nl) + + +@pytest.fixture +def cwd(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + for var in [v for v in os.environ if v.startswith("DATA_MASKING_")]: + monkeypatch.delenv(var, raising=False) + (tmp_path / "src.txt").write_text("сержант Петренко Іван Петрович, ІПН 1234567890\n", encoding="utf-8") + return tmp_path + + +def _masked(cwd): + assert mask_cli.main(["-i", "src.txt", "-o", "out.txt", "--no-report"]) == 0 + return str(next(cwd.glob("masking_map_*"))) + + +class TestCli: + def test_log_file_is_input(self, cwd): + before = (cwd / "src.txt").read_bytes() + with pytest.raises(SystemExit) as e: + mask_cli.main(["-i", "src.txt", "-o", "out.txt", "--no-report", "--log-file", "src.txt"]) + assert e.value.code == 2 and (cwd / "src.txt").read_bytes() == before + + def test_log_file_is_mapping(self, cwd): + mapping = _masked(cwd) + before = Path(mapping).read_bytes() + assert unmask_cli.main(["out.txt", "-m", mapping, "-o", "r.txt", "--log-file", mapping]) == 2 + assert Path(mapping).read_bytes() == before + + @pytest.mark.parametrize("data", [{}, {"name": "config"}, {"version": "3.1.21"}]) + def test_not_a_mapping(self, cwd, data): + _masked(cwd) + (cwd / "c.json").write_text(json.dumps(data), encoding="utf-8") + assert unmask_cli.main(["out.txt", "-m", "c.json", "-o", "r.txt"]) == 1 + + @pytest.mark.parametrize("bad", [" ", ".", " - "]) + def test_mask_without_letters(self, bad): + with pytest.raises(ValueError, match="empty"): + validate_mapping_schema({"mappings": {"surname": {"Коваленко": {"masked_as": bad, "instances": [1]}}}}) + + @pytest.mark.parametrize("bad", ["x", [1], [{"mappings": 5}]]) + def test_legacy_re_mask_passes(self, bad): + with pytest.raises(ValueError): + validate_mapping_schema({"mappings": {}, "re_mask_passes": bad}) + + @pytest.mark.parametrize("codec", ["base64", "rot13", "hex"]) + def test_not_a_text_encoding(self, cwd, codec): + with pytest.raises(ValueError, match="not a text encoding"): + _textio.normalize_encoding(codec) + mapping = _masked(cwd) + data = json.loads(Path(mapping).read_text(encoding="utf-8")) + data["input_encoding"] = codec + Path(mapping).write_text(json.dumps(data), encoding="utf-8") + assert unmask_cli.main(["out.txt", "-m", mapping, "-o", "r.txt"]) == 1 + + def test_mapping_with_bom(self, cwd): + mapping = _masked(cwd) + Path(mapping).write_bytes(b"\xef\xbb\xbf" + Path(mapping).read_bytes()) + assert unmask_cli.main(["out.txt", "-m", mapping, "-o", "r.txt"]) == 0 + assert (cwd / "r.txt").read_bytes() == (cwd / "src.txt").read_bytes() + + @pytest.mark.parametrize("cli", [mask_cli, unmask_cli]) + def test_too_deep_json_is_an_error(self, cli, monkeypatch, capsys): + # Глибина, на якій Python кидає RecursionError, залежить від версії й + # платформи, тож тут — сама обгортка main() + def deep(argv=None): + raise RecursionError("maximum recursion depth exceeded") + monkeypatch.setattr(cli, "_main", deep) + assert cli.main([]) == 1 + assert "nested too deeply" in capsys.readouterr().err + + def test_output_directory(self, cwd): + (cwd / "d").mkdir() + assert mask_cli.main(["-i", "src.txt", "-o", "d", "--no-report"]) == 2 + assert mask_cli.main(["-i", "src.txt", "-o", "nodir/x.txt", "--no-report"]) == 2 + assert not list(cwd.glob("masking_map_*")) + + def test_auto_mode_honours_output(self, cwd): + assert mask_cli.main(["-i", "src.txt", "--no-report"]) == 0 + assert unmask_cli.main(["-o", "mine.txt"]) == 0 + assert (cwd / "mine.txt").read_bytes() == (cwd / "src.txt").read_bytes() + + @pytest.mark.skipif(not _HAS_CRYPTO, reason="cryptography not installed") + def test_scrypt_work_limit(self): + from datamasking.extras.security import MappingSecurityManager + with pytest.raises(ValueError, match="too large"): + MappingSecurityManager.check_kdf({"kdf": "scrypt", "n": 1 << 20, "r": 8, "p": 16}) + MappingSecurityManager.check_kdf({"kdf": "scrypt", "n": 1 << 14, "r": 8, "p": 1})