Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 83 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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

Expand Down
12 changes: 12 additions & 0 deletions datamasking/_fsutil.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 5 additions & 1 deletion datamasking/_textio.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]]:
Expand Down
6 changes: 6 additions & 0 deletions datamasking/extras/security.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 23 additions & 1 deletion datamasking/masking/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -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':
Expand Down
19 changes: 17 additions & 2 deletions datamasking/masking/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'(?<!\d)(\d{1,2})' # day (не хвіст числа: «123 травня»)
r'(?:\s*["\u201d\u201c»]?\s*)' # optional closing quote + space
r'\s*(?:["\u201d\u201c»\'\u2019]\s*)?' # optional closing quote + space
r'(' + _MONTHS_UA + r')' # month name
r'\s+(\d{4})(?!\d)' # year
r'(?:\s+року)?', # optional "року"
Expand Down Expand Up @@ -275,6 +278,18 @@ def set_faker_locale(locale: str) -> 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

Expand Down
21 changes: 19 additions & 2 deletions datamasking/masking/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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-к» (кадровий наказ) з дефісом —
Expand All @@ -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',
Expand All @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion datamasking/masking/declension.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]]:
Expand Down
Loading
Loading