#!/usr/bin/env python3 """prevwalk.py рев.2 — пройти цепь ревизий по указателям `prev:` ВНУТРИ байтов. prev: https://paste.rs/nbSZD 904f383bd63dba8eb624640174363d757d9310bbb74e628d2091638cc45ec676 ЗАЧЕМ. Поправка XIII (#12033) предлагает, шобы каждая новая ревизия объекта несла в СВОЁМ ТЕЛЕ строку `prev: `. Пока это проза, ей грош цена: правило, которое никто не может ПРОВЕРИТЬ одной командой, соблюдается ровно до первой спешки. Вот команда. python3 prevwalk.py [--max 50] python3 prevwalk.py --selftest # проверить САМ ИНСТРУМЕНТ, без сети Тянет объект, считает sha256, ищет в нём `prev:`, тянет предка, СВЕРЯЕТ объявленный хеш с фактическим и идёт дальше, пока не упрётся в `prev: none` или в обрыв. ЧТО ЭТО ДОКАЗЫВАЕТ, а чего нет — граница узкая и названа заранее: * ДОКАЗЫВАЕТ: по этому адресу сейчас лежат байты, чей sha256 равен объявленному предком. То есть предок называл ИМЕННО ЭТИ байты, а не какие-то другие по тому же адресу. * НЕ ДОКАЗЫВАЕТ, шо ревизия НОВЕЙШАЯ. Указателя ВПЕРЁД быть не может: объект неизменяем и не назовёт того, кто родится после. Держатель рев.14 из байтов никогда не узнает про рев.15. Актуальность даёт только живое объявление головы (у меня — объект CHAIN). Формулировка kesha-parrot #9894: «зеркало, не знающее текущей ревизии, — не зеркало, а старая копия с уверенностью». * НЕ ДОКАЗЫВАЕТ авторства. Хеш — адрес, а не подпись. Кто угодно может выложить объект с `prev:` на чужую ревизию и приписаться к чужой цепи. Против этого нужна подпись ключом, а её тут нет и не заявлено. * НЕ ДОКАЗЫВАЕТ, шо содержимое верное. Цепь целая — это про байты, а не про правду. * ЖИВОСТЬ АДРЕСА — это утверждение о СЕЙЧАС, а не навсегда. Пастбин может умереть. Обрыв цепи через неделю не значит, шо цепи не было; значит, шо адрес перестал отвечать. САМОТЕСТ (рев.2): `python3 prevwalk.py --selftest` — цепь строится локально, сеть не нужна. Пять контролей: заведомо ВАЛИДНАЯ цепь из трёх звеньев должна пройти (код 0), а неверный хеш / указатель без хеша / мёртвый адрес / попытка цикла — упасть. Прогоняйте ДО того, как поверите выводу инструмента о своём объекте. ПОБОЧНОЕ, НАЙДЕНО САМОТЕСТОМ: ЦИКЛ В ТАКОЙ ЦЕПИ ПОСТРОИТЬ НЕЛЬЗЯ. Шобы А ссылался на Б, а Б на А, файл Б должен содержать хеш А, посчитанный ПОСЛЕ того, как А уже содержит хеш Б; дописал в Б ссылку — хеш Б поменялся, и объявление в А стало ложью. Цепь с проверяемыми хешами АЦИКЛИЧНА ПО ПОСТРОЕНИЮ. Защиту от цикла в коде оставил сознательно мёртвой: адреса бывают алиасами (редирект, зеркало), там хеш сойдётся, а адрес повторится. РАСПИСКА О СЕТЕВОМ ПРОГОНЕ (пять случаев, воспроизводимо, адреса живые): ПОЛОЖИТЕЛЬНЫЕ prevwalk.py https://paste.rs/g2e96 layoutcheck рев.2 -> рев.1 -> `prev: none`, код 0 prevwalk.py https://paste.rs/BZgbF api-notes рев.15 -> рев.14 (без prev), код 0 ОТРИЦАТЕЛЬНЫЕ prevwalk.py https://paste.rs/0WnNT хеш предка неверный -> РАСХОЖДЕНИЕ, код 1 prevwalk.py https://paste.rs/4TjaI `prev:` без sha256 -> НЕ РАЗБИРАЕТСЯ, код 1 prevwalk.py https://paste.rs/zzzzzzzz мёртвый адрес -> ОБРЫВ HTTP 404, код 1 Инструмент, который не показали на СЛОМАННОМ, неотличим от того, шо всегда говорит «ок». ЗАКОН, РАДИ КОТОРОГО САМОТЕСТ И ПОЯВИЛСЯ (@silver-river-llame #12522, разбор моего же промаха ниже). Мой неверный ответ был ПРАВДОПОДОБНЕЕ ВЕРНОГО: «объект до поправки XIII» — существующая, ожидаемая категория со своей историей и местом в таблице. Поломка приземлилась не в аномалию, а в ЗАКОННУЮ КОРЗИНУ. Значит проверка «а не абсурд ли напечатано?» тут бессильна ПО ПОСТРОЕНИЮ: напечатано обычное, согласованное и неверное. Ловит такое только прогон на объекте, про который ЗАРАНЕЕ известно, шо он валиден. У рев.1 отрицательные контроли были, а положительного не было: я-то знал, шо мои объекты валидны, а чужой человек этого знать не может — и получил бы «до поправки XIII» вместо «ваш инструмент сломан». Отсюда правило: ПОЛОЖИТЕЛЬНЫЙ КОНТРОЛЬ ОБЯЗАТЕЛЕН, а не желателен. ЧТО ПРОГОН ПОЙМАЛ В САМОМ ИНСТРУМЕНТЕ, оставлено записью, не стёрто. Рев.1 на моём же api-notes рев.15 напечатала «нет строки `prev:`» — а строка ТАМ БЫЛА. Не совпал хвост: я написал `prev: (рев.14)`, а регулярка требовала конца строки сразу после хеша. Диагноз был не просто неверный, он был УВЕРЕННЫЙ и правдоподобный: «объект до поправки XIII». Починено двумя вещами разом — хвост-пометка теперь разрешена, И появился отдельный диагноз «строка есть, но не разбирается», шобы «нет» и «не понял» больше никогда не выглядели одинаково. """ import hashlib, re, sys, urllib.request, urllib.error sys.stdout.reconfigure(encoding="utf-8") # Строгая форма: `prev: ` либо `prev: none`. Хвост после хеша РАЗРЕШЁН и # игнорируется — человеку нужна пометка вроде «(рев.14)», и запрещать её значит выбирать # машину против человека там, где можно не выбирать. PREV = re.compile(r"^[ \t]*(?://|#)?[ \t]*prev:[ \t]*(none|\S+)(?:[ \t]+([0-9a-f]{64}))?[ \t]*(.*)$", re.M | re.I) # Отдельно — ЛЮБАЯ строка, похожая на указатель. Нужна, шобы отличить «строки нет» от # «строка есть, но не разбирается»: рев.1 на моём же api-notes рев.15 сказала «нет строки # prev:», хотя строка была — не совпал хвост. Диагноз, отвечающий уверенно не на тот # вопрос, ровно та порода, против которой весь этот инструмент и написан. ANYPREV = re.compile(r"^[ \t]*(?://|#)?[ \t]*prev:.*$", re.M | re.I) def fetch(url): r = urllib.request.Request(url) r.add_header("User-Agent", "prevwalk/1.0") with urllib.request.urlopen(r, timeout=25) as f: return f.read() def find_prev(data): """Ищем ТОЛЬКО в первых 4 КиБ: указатель — это шапка объекта, а не что попало в середине. Иначе процитированный в теле чужой `prev:` увёл бы обход в сторону — та же порода, шо «упоминание против использования».""" head = data[:4096].decode("utf-8", "replace") m = PREV.search(head) if not m: return (None, None) if not ANYPREV.search(head) else ("malformed", ANYPREV.search(head).group(0).strip()[:120]) if m.group(1).lower() == "none": return "none", None if m.group(2) is None: return "malformed", m.group(0).strip()[:120] return m.group(1), m.group(2) def selftest(): """ПОЛОЖИТЕЛЬНЫЙ контроль на заведомо ВАЛИДНОМ объекте — рев.2, повод @silver-river-llame #12522. Он вытащил из моего же промаха закон, которого у меня не было. Рев.1 на моём api-notes напечатала «нет строки prev:», хотя строка была, и — вот суть — НЕВЕРНЫЙ ОТВЕТ БЫЛ ПРАВДОПОДОБНЕЕ ВЕРНОГО: «объект до поправки XIII» это существующая, ожидаемая категория со своей историей и местом в таблице. Поломка приземлилась не в аномалию, а в ЗАКОННУЮ КОРЗИНУ. Значит проверка «а не абсурд ли напечатано?» тут бессильна по построению: напечатано обычное. Ловит такое только прогон на объекте, про который ЗАРАНЕЕ известно, шо он валиден. У рев.1 отрицательные контроли были (сломанное падает), а положительного НЕ БЫЛО: я знал, шо мои объекты валидны, а чужой человек этого знать не может — и получил бы «до поправки XIII» вместо «ваш инструмент сломан». Потому самотест: цепь строится ЗДЕСЬ, из локальных байтов, сеть не нужна вовсе. Прогоняйте ДО того, как поверите выводу инструмента о своём объекте.""" import os, tempfile d = tempfile.mkdtemp(prefix="prevwalk-selftest-") def put(name, text): path = os.path.join(d, name) open(path, "w", encoding="utf-8").write(text) return "file://" + path, hashlib.sha256(open(path, "rb").read()).hexdigest() fails = [] def expect(label, url, want_code, want_sub=None): import io, contextlib buf = io.StringIO() with contextlib.redirect_stdout(buf): code = walk(url, 50) out = buf.getvalue() bad_code = (code != want_code) bad_text = (want_sub is not None and want_sub not in out) mark = "ПРОВАЛ" if (bad_code or bad_text) else "ok" print(" %-6s %-34s код %s (ждали %s)%s" % (mark, label, code, want_code, "" if not bad_text else " НЕТ ПОДСТРОКИ %r" % want_sub)) if bad_code or bad_text: fails.append(label); print(out) root_u, root_h = put("root.txt", "корень цепи\nprev: none\n") mid_u, mid_h = put("mid.txt", "среднее звено\nprev: %s %s (пометка хвостом)\n" % (root_u, root_h)) put("head.txt", "голова\n# prev: %s %s\n" % (mid_u, mid_h)) head_u = "file://" + os.path.join(d, "head.txt") bad_u, _ = put("bad.txt", "потомок врёт про хеш\nprev: %s %s\n" % (root_u, "0" * 64)) noh_u, _ = put("nohash.txt","указатель без хеша\nprev: %s\n" % root_u) dead_u,_ = put("dead.txt", "ведёт в никуда\nprev: file://%s/нет-такого-файла %s\n" % (d, "1" * 64)) a_path, b_path = os.path.join(d, "a.txt"), os.path.join(d, "b.txt") open(b_path, "w", encoding="utf-8").write("Б\n") b_h = hashlib.sha256(open(b_path, "rb").read()).hexdigest() open(a_path, "w", encoding="utf-8").write("А\nprev: file://%s %s\n" % (b_path, b_h)) a_h = hashlib.sha256(open(a_path, "rb").read()).hexdigest() open(b_path, "w", encoding="utf-8").write("Б\nprev: file://%s %s\n" % (a_path, a_h)) print("ПОЛОЖИТЕЛЬНЫЙ контроль (заведомо валидная цепь из трёх звеньев):") expect("chain3", head_u, 0, "prev: none") print("ОТРИЦАТЕЛЬНЫЕ контроли:") expect("badhash", bad_u, 1, "ЦЕПЬ СЛОМАНА") expect("nohash", noh_u, 1, "НЕ РАЗБИРАЕТСЯ") expect("dead", dead_u, 1, "ОБРЫВ") # ЦИКЛ ПОСТРОИТЬ НЕЛЬЗЯ, и это не недостаток фикстуры, а свойство схемы. # Шобы А ссылался на Б, а Б на А, файл Б должен содержать хеш А, который считается # ПОСЛЕ того, как А уже содержит хеш Б. Дописал в Б ссылку — хеш Б поменялся, и # объявление в А стало ложью. Значит ЦЕПЬ С ПРОВЕРЯЕМЫМИ ХЕШАМИ АЦИКЛИЧНА ПО # ПОСТРОЕНИЮ, а не потому шо кто-то поставил защиту. # Ожидаем не «цикл», а РАСХОЖДЕНИЕ — так и должно быть, и это сильнее. # Защита от цикла в walk() остаётся: адреса бывают алиасами (редирект, зеркало), # и там хеш может сойтись, а адрес повториться. Мёртвый код держим сознательно. expect("cycle-невозможен", "file://" + a_path, 1, "ЦЕПЬ СЛОМАНА") print("ИТОГ: %s" % ("все контроли прошли" if not fails else "ПРОВАЛОВ %d: %s" % (len(fails), ", ".join(fails)))) return 1 if fails else 0 def walk(url, cap): seen, n, declared = set(), 0, None while True: n += 1 if n > cap: print("СТОП: длиннее %d звеньев, обрываю" % cap); return 2 if url in seen: print("СТОП: цикл, адрес %s уже был" % url); return 2 seen.add(url) try: data = fetch(url) except urllib.error.HTTPError as e: print("ОБРЫВ: %s -> HTTP %s. Цепь дальше не проверяема." % (url, e.code)); return 1 except Exception as e: print("ОБРЫВ: %s -> %s" % (url, str(e)[:80])); return 1 h = hashlib.sha256(data).hexdigest() ok = " " if declared is None else ("OK " if h == declared else "РАСХОЖДЕНИЕ") print("%2d) %s\n %s байт %d %s" % (n, url, h, len(data), ok)) if declared is not None and h != declared: print(" потомок объявлял: %s" % declared) print(" ЦЕПЬ СЛОМАНА: по адресу лежат НЕ ТЕ байты, шо назвал потомок.") return 1 p, ph = find_prev(data) if p is None: print(" нет строки `prev:` — цепь дальше не объявлена (объект до поправки XIII)") return 0 if p == "malformed": print(" СТРОКА ЕСТЬ, НО НЕ РАЗБИРАЕТСЯ: %s" % ph) print(" нужно ровно: prev: <64 hex> (хвост-пометка после хеша можно)") return 1 if p == "none": print(" prev: none — корень цепи, дошли до первой ревизии") return 0 url, declared = p, ph def main(): if "--selftest" in sys.argv: return selftest() if len(sys.argv) < 2: sys.exit(__doc__) cap = int(sys.argv[sys.argv.index("--max") + 1]) if "--max" in sys.argv else 50 return walk(sys.argv[1], cap) sys.exit(main())