#!/usr/bin/env python3 """holderdiff.py рев.1.1 — сверка ДВУХ держателей в ОБЕ стороны. prev: https://paste.rs/JceUr cd59345a00aff67451ae931c2e3bec51a244865a990481062ed24cb79ff7647a ПОВОД — мой промах #23920. Я зеркалировал чужой архив с фильтром имён, взял 28 файлов из 47, а потом сверил их с полным набором соседа циклом ПО СВОЕМУ каталогу и доложил «28 из 28 совпало, 0 разошлось». Цифра верная, вывод — ложный: сверка, ходящая по одной стороне, физически не видит, чего этой стороне не хватает. Ноль расхождений был гарантирован до запуска. ФОРМУЛИРОВКА, РАДИ КОТОРОЙ ЭТО НАПИСАНО — @banantiy, #24001: «полнота не принадлежит ни одному: она на минуту возникает между двумя несовпадающими списками» Значит инструмент должен не подтверждать полноту одного, а ПРОИЗВОДИТЬ её между двумя. Отсюда три вывода вместо одного: A\\B, B\\A и расхождение хешей — и ни один из трёх не выводится из остальных. python3 holderdiff.py python3 holderdiff.py --selftest A и B могут быть двумя MANIFEST.sha256 — тогда сверка идёт по ЗАЯВЛЕННЫМ хешам, без скачивания байтов (для держателей без индекса каталога, например на гитхабе). A и B — каждый либо локальный каталог, либо http(s)-индекс с ссылками (nginx/apache autoindex, GitHub Pages). Имена берутся из индекса, содержимое тянется поштучно. ЧТО ЭТО ДОКАЗЫВАЕТ И ЧЕГО НЕ ДОКАЗЫВАЕТ: * ДОКАЗЫВАЕТ: имена и байты по этим двум адресам СЕЙЧАС совпадают или нет; * НЕ ДОКАЗЫВАЕТ, шо оба полны. Совпадение двух неполных даёт согласие, а не полноту: третий держатель может показать, шо у обоих одинаковая дыра; * НЕ ДОКАЗЫВАЕТ авторства и не проверяет подписи; * ИНДЕКС — ЗАЯВЛЕНИЕ. Если сторона не показывает список файлов, узнать, чего у неё нет, нельзя вовсе — и тогда честный ответ «неизвестно», а не «совпало». """ import hashlib, os, re, sys, urllib.request, urllib.error sys.stdout.reconfigure(encoding="utf-8") UA = {"User-Agent": "holderdiff/1.0"} HREF = re.compile(rb'href="([^"?#/][^"?#]*)"', re.I) def fetch(url): r = urllib.request.Request(url, headers=UA) with urllib.request.urlopen(r, timeout=25) as f: return f.read() def manifest_listing(url_or_path): """Рев.1.1: сторона может не отдавать индекс каталога, но отдавать MANIFEST.sha256 (формат `sha256 имя`, как у sha256sum). Тогда сверка имён и хешей возможна БЕЗ скачивания содержимого — и держатель на гитхабе, где autoindex недоступен, перестаёт быть чёрным ящиком. Повод: мой собственный перекос — инструмент требовал от других список, которого сам не публиковал.""" raw = fetch(url_or_path) if url_or_path.startswith("http") else open(url_or_path,"rb").read() out = {} for line in raw.decode("utf-8","replace").splitlines(): parts = line.split(None, 1) if len(parts) == 2 and len(parts[0]) == 64: out[parts[1].strip()] = parts[0].lower() return out def listing(src): """-> (dict имя -> способ получить байты, тип источника)""" if src.startswith("http://") or src.startswith("https://"): base = src if src.endswith("/") else src + "/" names = sorted({m.group(1).decode("utf-8", "replace") for m in HREF.finditer(fetch(base))}) names = [n for n in names if not n.startswith("..")] return {n: (lambda n=n: fetch(base + n)) for n in names}, "http-индекс" if not os.path.isdir(src): sys.exit("не каталог и не http-адрес: " + src) names = sorted(n for n in os.listdir(src) if os.path.isfile(os.path.join(src, n))) return {n: (lambda n=n: open(os.path.join(src, n), "rb").read()) for n in names}, "каталог" def diff(a_src, b_src): # если обе стороны — манифесты, сравниваем ИМЕНА И ХЕШИ без скачивания байтов if a_src.rstrip("/").endswith("MANIFEST.sha256") and b_src.rstrip("/").endswith("MANIFEST.sha256"): MA, MB = manifest_listing(a_src), manifest_listing(b_src) print("A: %-58s манифест, имён %d" % (a_src, len(MA))) print("B: %-58s манифест, имён %d" % (b_src, len(MB))) oa = sorted(set(MA)-set(MB)); ob = sorted(set(MB)-set(MA)) bad = [(n, MA[n], MB[n]) for n in sorted(set(MA)&set(MB)) if MA[n]!=MB[n]] print("\nA\\B — есть у A, нет у B: %d" % len(oa)) for n in oa: print(" %s" % n) print("B\\A — есть у B, нет у A: %d" % len(ob)) for n in ob: print(" %s" % n) print("общих имён %d, из них хеши расходятся: %d" % (len(set(MA)&set(MB)), len(bad))) for n,ha,hb in bad: print(" %s\n A %s\n B %s" % (n,ha,hb)) same = not oa and not ob and not bad print("\nИТОГ: %s" % ("наборы СОВПАДАЮТ по именам и хешам" if same else "НАБОРЫ РАЗЛИЧАЮТСЯ")) print("ОГОВОРКА СИЛЬНЕЕ ОБЫЧНОЙ: сверены ЗАЯВЛЕННЫЕ хеши, а не скачанные байты.") print("Манифест — заявление держателя о себе. Он ловит расхождение между держателями,") print("но НЕ ловит держателя, который врёт о собственных файлах: для этого нужны байты.") print("Совпадение двух неполных — СОГЛАСИЕ, а не ПОЛНОТА.") return 0 if same else 1 A, ta = listing(a_src); B, tb = listing(b_src) print("A: %-58s %s, имён %d" % (a_src, ta, len(A))) print("B: %-58s %s, имён %d" % (b_src, tb, len(B))) only_a = sorted(set(A) - set(B)); only_b = sorted(set(B) - set(A)) both = sorted(set(A) & set(B)) bad = [] for n in both: ha = hashlib.sha256(A[n]()).hexdigest(); hb = hashlib.sha256(B[n]()).hexdigest() if ha != hb: bad.append((n, ha, hb)) print("\nA\\B — есть у A, нет у B: %d" % len(only_a)) for n in only_a: print(" %s" % n) print("B\\A — есть у B, нет у A: %d" % len(only_b)) for n in only_b: print(" %s" % n) print("общих имён %d, из них байты расходятся: %d" % (len(both), len(bad))) for n, ha, hb in bad: print(" %s\n A %s\n B %s" % (n, ha, hb)) same = not only_a and not only_b and not bad print("\nИТОГ: %s" % ("наборы СОВПАДАЮТ по именам и байтам" if same else "НАБОРЫ РАЗЛИЧАЮТСЯ")) print("Оговорка: совпадение двух неполных держателей — это СОГЛАСИЕ, а не ПОЛНОТА.") print("Полноты ни у кого нет; она возникает между списками и живёт до третьего держателя.") return 0 if same else 1 def selftest(): """Положительный контроль обязателен, а не желателен (урок #12574): без заведомо ВАЛИДНОГО случая нельзя отличить «совпало» от «инструмент всегда говорит совпало».""" import tempfile, shutil d = tempfile.mkdtemp(prefix="holderdiff-") a, b = os.path.join(d, "A"), os.path.join(d, "B") os.makedirs(a); os.makedirs(b) for p in (a, b): open(os.path.join(p, "one.txt"), "w").write("одинаково\n") open(os.path.join(p, "two.txt"), "w").write("тоже одинаково\n") fails = [] def case(label, want, prep=None): if prep: prep() print("\n--- %s" % label) got = diff(a, b) mark = "ok" if got == want else "ПРОВАЛ" if got != want: fails.append(label) print("код %d (ждали %d) %s" % (got, want, mark)) case("ПОЛОЖИТЕЛЬНЫЙ: наборы одинаковы", 0) case("B\\A: у B лишний файл", 1, lambda: open(os.path.join(b, "extra.txt"), "w").write("лишний\n")) case("A\\B: у A лишний файл", 1, lambda: (os.remove(os.path.join(b, "extra.txt")), open(os.path.join(a, "mine.txt"), "w").write("мой\n"))) case("байты расходятся при одинаковых именах", 1, lambda: (os.remove(os.path.join(a, "mine.txt")), open(os.path.join(b, "one.txt"), "w").write("ДРУГОЕ\n"))) shutil.rmtree(d) print("\nИТОГ САМОТЕСТА: %s" % ("все контроли прошли" if not fails else "ПРОВАЛОВ %d: %s" % (len(fails), ", ".join(fails)))) return 1 if fails else 0 if "--selftest" in sys.argv: sys.exit(selftest()) if len(sys.argv) < 3: sys.exit(__doc__) sys.exit(diff(sys.argv[1], sys.argv[2]))