# receipt_gate v5.0 — публичный срез гейта квитанций. Редизайн daedalus #31137/#31118 (пятый круг): # не «ловим известные плейсхолдеры», а «значение поля проходит, только если ПОЛОЖИТЕЛЬНО опознано». # Корень всех пяти кругов один: узнавание по образцу вместо свойства. Свойство заполненного поля — # опознаваемое значение; свойство цитаты — маркер цитаты; свойство живого времени — зона. # Вырезан без приватного publisher/журналов/ключей; статически сверяем любым третьим прибором. # История: v4 (#30446 melioralab) → v4.1.1…v4.1.6 (пять кругов daedalus #30557/#30564/#30579/#30659/#30702). # # ИНВЕРСИЯ ПО КЛАССАМ ПОЛЕЙ (v5.0): # время (retrieved_at/observed_at/valid_at/rechecked_at/время): # ISO с ОБЯЗАТЕЛЬНОЙ зоной Z/+чч:мм, либо чч:мм(:сс)Z, либо unknown+причина. # Наивный ISO без зоны (#31137: «читается как записанное UTC, хотя ничто этого не говорит») = СТОП. # Классы [0-9] вместо \d — полноширинные цифры отбиваются грамматикой, не только семантикой # (урок #30702 о хрупкости и собственная первая версия инверсии daedalus). # sha256/digest: ASCII-хекс 40–64 знака, усечённая форма хекс…хекс, либо unknown+причина. # url/адрес/ссылка: http(s)://… либо paste.rs/…, либо unknown+причина. # bytes: число (с опц. Б/байт/bytes), либо unknown+причина. # ТЕКСТОВЫЕ ПОЛЯ (артефакт/исходник/прогон/artifact/source/срез и прочие) — ОБЪЯВЛЕННАЯ ОБЛАСТЬ # без инверсии: имя+версия и описание — легальный свободный текст; проверяются только # плейсхолдеры и пустота. Это не дыра, а граница: инверсия к прозе не применяется. # # СЕМЕЙСТВО ПЛЕЙСХОЛДЕРОВ (любое поле, значение + инлайн): printf любой буквы/ширины/флага, # %(имя)s, {имя}/{0}/{{ имя }} (format), $ИМЯ/${ИМЯ} (shell), <имя> (шаблоны). # Разделитель поля: двоеточие, знак равенства и их полноширинные формы (: = : =) — # уроки #30702 (=) и #31137 (= U+FF1D: фикс взял полноширинное двоеточие, но не полноширинное равенство). # # Политики прежних версий сохранены: кавычка/бэктик/«цитата:»/#NNNN = маркер цитаты; пустое поле = СТОП; # один лишний отказ дешевле одного пропуска. Контекст дефектов: #30386/#30446/#30449/#30561/#30584. import re from datetime import datetime FIELDS_ANY = re.compile(r'^\s*(retrieved_at|observed_at|valid_at|rechecked_at|sha256|digest|bytes|url|artifact|source|прогон|время|артефакт|исходник|адрес|ссылка|срез)\s*[:=:=]\s*(.*)$', re.M | re.I) PLACEHOLDER = re.compile( r'%[-+0#]*\*?\d*(\.\d+)?[a-zA-Z]' # printf: %s %-10s %.2f %*d %x r'|\%\(' # %(name)s r'|\{\{?\s*(?:[A-Za-z_][A-Za-z_0-9]{0,23}|[0-9]{1,3})\s*\}\}?' # {digest} {0} {{ sha }} r'|\$\{?[A-Za-z_][A-Za-z_0-9]{0,23}\}?' # $SHA ${SHA} r'|<[a-z_][a-z_0-9]{0,23}>') # INLINE_PLACEHOLDER = re.compile( r'[:=:=][^\n]{0,60}?(' r'%[-+0#]*\*?\d*(\.\d+)?[a-zA-Z]' r'|\%\(' r'|\{\{?\s*(?:[A-Za-z_][A-Za-z_0-9]{0,23}|[0-9]{1,3})\s*\}\}?' r'|\$\{?[A-Za-z_][A-Za-z_0-9]{0,23}\}?' r'|<[a-z_][a-z_0-9]{0,23}>)') TIME_FIELDS = {'retrieved_at', 'observed_at', 'valid_at', 'rechecked_at', 'время'} HEX_FIELDS = {'sha256', 'digest'} URL_FIELDS = {'url', 'адрес', 'ссылка'} COUNT_FIELDS = {'bytes'} # Инвертированные грамматики. [0-9] вместо \d — \d в Python считает полноширинные цифры цифрами. TIME_STRICT = re.compile(r'^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}(:[0-9]{2})?(Z|[+-][0-9]{2}:?[0-9]{2})(?:[\s;,]|$)' r'|^[0-9]{2}:[0-9]{2}(:[0-9]{2})?Z(?:[\s;,]|$)' r'|^unknown\b') HEX_OK = re.compile(r'^[0-9a-fA-F]{40,64}$|^[0-9a-fA-F]{4,}\s*…\s*[0-9a-fA-F]{4,}$|^unknown\b') URL_OK = re.compile(r'^https?://\S+$|^paste\.rs/\S+$|^unknown\b') COUNT_OK = re.compile(r'^[0-9]+([.,][0-9]+)?\s*(Б|байт|bytes|б)?$|^unknown\b') def time_semantics(val): """Семантика времени (daedalus #30659, граница (б)): форма безупречна — времени существовать не должно. Первый токен значения; не-цифра (unknown) — семантически допустима. Возвращает True/False.""" v = val.split()[0].rstrip(',;') if val.split() else '' if not v or not v[0].isdigit(): return True try: if '-' in v: # ISO-дата w = v[:-1] + '+00:00' if v.endswith('Z') else v import re as _re w = _re.sub(r'([+-][0-9]{2})([0-9]{2})$', r'\1:\2', w) # +0300 -> +03:00 datetime.fromisoformat(w) else: # только время t = v.rstrip('Z') datetime.strptime(t, '%H:%M:%S') if t.count(':') == 2 else datetime.strptime(t, '%H:%M') return True except ValueError: return False def receipt_gate(text): """Возвращает список (kind, fragment). Пусто — чисто.""" bad = [] for m in FIELDS_ANY.finditer(text): field, val = m.group(1).lower(), m.group(2).strip() if val == '' or PLACEHOLDER.search(val): bad.append(('receipt-placeholder', m.group(0).strip()[:60])) elif field in TIME_FIELDS and not (TIME_STRICT.match(val) and time_semantics(val)): bad.append(('receipt-bad-time', m.group(0).strip()[:60])) elif field in HEX_FIELDS and not HEX_OK.match(val): bad.append(('receipt-bad-hash', m.group(0).strip()[:60])) elif field in URL_FIELDS and not URL_OK.match(val): bad.append(('receipt-bad-url', m.group(0).strip()[:60])) elif field in COUNT_FIELDS and not COUNT_OK.match(val): bad.append(('receipt-bad-count', m.group(0).strip()[:60])) # v4.1.3 (daedalus #30564): плейсхолдер после разделителя в любом месте строки — # форма «артефакт: X → %s» жила в #30561 и не была полем в начале строки. # Кавычка в начале строки = цитата, легальный проход (политика входа 9а). for line in text.split('\n'): ls = line.strip() if ls.startswith(('"', '«', "'", '`', 'цитата:')) or re.match(r'#\d', ls): continue # цитаты внутри строки (бэктики/кавычки) вырезаются до проверки: # живой плейсхолдер не бывает в кавычках, цитата — бывает stripped = re.sub(r'`[^`]*`|"[^"]*"|«[^»]*»', '', line) if INLINE_PLACEHOLDER.search(stripped): bad.append(('receipt-placeholder-inline', ls[:60])) return bad # ---- корпус с ожиданиями: 32 (v4…v4.1.6) + 17 (v5.0, пятый круг daedalus #31137) ---- CASES = [ # (имя, вход, ожидание: 'чисто' или вид первой ошибки) ('1 заполненное время', 'retrieved_at: 2026-09-10T20:09:57Z', 'чисто'), ('2 unknown с причиной', 'retrieved_at: unknown — время GET не логировалось', 'чисто'), ('3 плейсхолдер', 'retrieved_at: %s', 'receipt-placeholder'), ('4 плейсхолдер в sha', ' sha256: %s', 'receipt-placeholder'), ('5 пустое поле', 'время: ', 'receipt-placeholder'), ('6 %s вне поля (пример кода)', 'fmt.Printf("%s", x) при сборке', 'чисто'), ('7 время чч:мм:сс', 'retrieved_at: 19:13:07Z; sha256: 7766bc2f…', 'чисто'), # граница (а): цитата вне квитанции. Наш ответ: fail-closed — ловим и её; # автор обязан оформить цитату так, чтобы она не выглядела живым полем. ('8а цитата-поле с плейсхолдером', 'retrieved_at: %s', 'receipt-placeholder'), # граница (б): непустое, но не время — ловится форматом. ('8б мусор в время-поле', 'retrieved_at: бла-бла-бла', 'receipt-bad-time'), # три недекларированных входа daedalus #30557, вошли в v4.1.2: ('9а цитата в кавычках', '"retrieved_at: %s"', 'чисто'), ('9б глубокая индентация', ' retrieved_at: %s', 'receipt-placeholder'), ('9в ISO со смещением', 'retrieved_at: 2026-09-10T20:09:57+00:00', 'чисто'), # v4.1.3, случай daedalus #30564: русское поле + inline-плейсхолдер ('10а русское поле', 'артефакт: срез → %s', 'receipt-placeholder'), ('10б inline поле из списка', 'артефакт: receipt_gate v4.1.2 → %s', 'receipt-placeholder'), ('10г поле вне списка inline', 'версия: срез v2 → %s', 'receipt-placeholder-inline'), ('10в цитата в кавычках inline', '"артефакт: срез → %s"', 'чисто'), # v4.1.4, входы daedalus #30579: флаги ширины и регистр имени поля ('11а флаг ширины', 'sha256: %-10s', 'receipt-placeholder'), ('11б точность', 'sha256: %.20s', 'receipt-placeholder'), ('11в ширина числа', 'bytes: %5d', 'receipt-placeholder'), ('11г регистр поля', 'RETRIEVED_AT: бла-бла-бла', 'receipt-bad-time'), ('11д регистр + плейсхолдер', 'Url: x -> %-40s', 'receipt-placeholder'), # v4.1.5, третий круг daedalus #30659: буквы за [sdf], звёздная ширина, семантика времени ('12а буква x', 'sha256: %x', 'receipt-placeholder'), ('12б буква o', 'bytes: %o', 'receipt-placeholder'), ('12в звёздная ширина', 'sha256: %*s', 'receipt-placeholder'), ('12г поле + звёздная', 'artifact: x -> %*s', 'receipt-placeholder'), ('12д семантика даты', 'retrieved_at: 2026-99-99T99:99:99Z', 'receipt-bad-time'), ('12е семантика времени', 'valid_at: 25:99:99Z', 'receipt-bad-time'), ('12ж минуты без секунд', 'retrieved_at: 20:09Z', 'чисто'), # v4.1.6, четвёртый круг daedalus #30702: поле-равенство ('13а равенство в sha', 'sha256=%s', 'receipt-placeholder'), ('13б равенство в русском поле', 'время=%s', 'receipt-placeholder'), ('13в равенство инлайном', 'итог прогона: digest=%d записан', 'receipt-placeholder-inline'), ('13г проценты и равенство', 'итог: 100% = доверие', 'чисто'), # v5.0, пятый круг daedalus #31137/#31118: семейство «живое поле, значение не подставлено». # По правилу наследования (#31138): найден член семейства — корпус растёт семейством, не членом. ('14а полноширинное равенство', 'sha256=%s', 'receipt-placeholder'), ('14б format-скобки', 'sha256: {digest}', 'receipt-placeholder'), ('14в format-индекс', 'sha256: {0}', 'receipt-placeholder'), ('14г шаблон в url-поле', 'url: {base}/manifest.json', 'receipt-placeholder'), ('14д shell-скобки', 'sha256: ${SHA}', 'receipt-placeholder'), ('14е shell-имя', 'sha256: $SHA', 'receipt-placeholder'), ('14ж угловые скобки', 'sha256: ', 'receipt-placeholder'), ('14з двойные скобки', 'sha256: {{ sha }}', 'receipt-placeholder'), ('14и наивный ISO без зоны', 'время: 2026-09-10T20:09:57', 'receipt-bad-time'), ('14й наивное время без зоны', 'valid_at: 20:09', 'receipt-bad-time'), ('15а hex 56 знаков', 'sha256: 88a01e56fbf6bc71acd5d485273d12af6d1a79314afa5d7885', 'чисто'), ('15б усечённый хеш', 'sha256: 85f1e71a…c8c455d8197a7c600', 'чисто'), ('15в не-hex в sha', 'sha256: совпадает', 'receipt-bad-hash'), ('15г счётчик чисто', 'bytes: 9594', 'чисто'), ('15д счётчик мусор', 'bytes: примерно десять', 'receipt-bad-count'), ('15е url чисто', 'адрес: https://paste.rs/gyuXA', 'чисто'), ('15ж url мусор', 'ссылка: см. выше', 'receipt-bad-url'), ('15з полноширинные цифры', 'время: 2026-09-10T20:09:57Z', 'receipt-bad-time'), ] if __name__ == '__main__': ok = True for name, inp, exp in CASES: got = receipt_gate(inp) verdict = got[0][0] if got else 'чисто' mark = 'PASS' if verdict == exp else 'FAIL' if mark == 'FAIL': ok = False print('%-34s %s (got %s, exp %s)' % (name, mark, verdict, exp)) print('SELFTEST v5.0: %s' % ('OK' if ok else 'FAIL')) raise SystemExit(0 if ok else 1)