Prepare Telegram exports for deterministic KB reconstruction (#168)
Some checks are pending
CI / lint-and-test (push) Waiting to run
Some checks are pending
CI / lint-and-test (push) Waiting to run
This commit is contained in:
parent
56ad1c41dc
commit
1472c8513a
2 changed files with 539 additions and 0 deletions
415
scripts/prepare_telegram_export_corpus.py
Normal file
415
scripts/prepare_telegram_export_corpus.py
Normal file
|
|
@ -0,0 +1,415 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Split a Telegram HTML export into hash-bound, compiler-sized source artifacts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from html.parser import HTMLParser
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any
|
||||
from urllib.parse import unquote, urlsplit
|
||||
|
||||
SCHEMA = "livingip.telegramExportCorpus.v1"
|
||||
PARSER_VERSION = "1"
|
||||
DEFAULT_MAX_SEGMENT_CHARS = 100_000
|
||||
ATTACHMENT_PREFIXES = frozenset(
|
||||
{"animations", "files", "photos", "round_video_messages", "stickers", "video_files", "voice_messages"}
|
||||
)
|
||||
VOID_TAGS = frozenset({"area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr"})
|
||||
SECRET_VALUE_RE = re.compile(
|
||||
r"(?i)(\b(?:[a-z][a-z0-9_]*(?:_?key|_?token|_?secret|_?password|_?passcode|_?credential)|"
|
||||
r"x-api-key|authorization)\b\s*[:=]\s*(?:bearer\s+)?)([\"']?)([a-z0-9_./+=:-]{8,})([\"']?)"
|
||||
)
|
||||
|
||||
|
||||
class ExportError(RuntimeError):
|
||||
"""Raised when an export cannot be converted without losing provenance."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TelegramMessage:
|
||||
message_id: str
|
||||
timestamp: str
|
||||
sender: str
|
||||
reply_to: str | None
|
||||
text: str
|
||||
links: tuple[str, ...]
|
||||
|
||||
|
||||
def sha256_bytes(value: bytes) -> str:
|
||||
return hashlib.sha256(value).hexdigest()
|
||||
|
||||
|
||||
def canonical_json_bytes(value: Any) -> bytes:
|
||||
return json.dumps(value, ensure_ascii=False, separators=(",", ":"), sort_keys=True).encode("utf-8")
|
||||
|
||||
|
||||
def _clean_text(chunks: list[str]) -> str:
|
||||
text = "".join(chunks).replace("\r\n", "\n").replace("\r", "\n")
|
||||
lines = [line.rstrip() for line in text.splitlines()]
|
||||
return "\n".join(lines).strip()
|
||||
|
||||
|
||||
def _timestamp(value: str) -> str:
|
||||
try:
|
||||
return datetime.strptime(value, "%d.%m.%Y %H:%M:%S UTC%z").isoformat()
|
||||
except ValueError as exc:
|
||||
raise ExportError(f"unsupported Telegram timestamp: {value}") from exc
|
||||
|
||||
|
||||
class TelegramHtmlParser(HTMLParser):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(convert_charrefs=True)
|
||||
self.stack: list[tuple[str, frozenset[str]]] = []
|
||||
self.current: dict[str, Any] | None = None
|
||||
self.message_depth: int | None = None
|
||||
self.last_sender: str | None = None
|
||||
self.messages: list[TelegramMessage] = []
|
||||
self.title_chunks: list[str] = []
|
||||
|
||||
def _inside(self, class_name: str) -> bool:
|
||||
return any(class_name in classes for _tag, classes in self.stack)
|
||||
|
||||
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
||||
values = {key: value or "" for key, value in attrs}
|
||||
classes = frozenset(values.get("class", "").split())
|
||||
if tag in VOID_TAGS:
|
||||
if self.current is not None and tag == "br" and self._inside("text"):
|
||||
self.current["text"].append("\n")
|
||||
return
|
||||
if tag == "div" and {"message", "default"}.issubset(classes):
|
||||
if self.current is not None:
|
||||
raise ExportError("nested Telegram message containers are unsupported")
|
||||
raw_id = values.get("id", "")
|
||||
if not re.fullmatch(r"message\d+", raw_id):
|
||||
raise ExportError(f"invalid Telegram message id: {raw_id or '<missing>'}")
|
||||
self.current = {
|
||||
"message_id": raw_id.removeprefix("message"),
|
||||
"timestamp": None,
|
||||
"sender": [],
|
||||
"reply_to": None,
|
||||
"text": [],
|
||||
"links": [],
|
||||
}
|
||||
self.message_depth = len(self.stack)
|
||||
self.stack.append((tag, classes))
|
||||
if self.current is None:
|
||||
return
|
||||
if tag == "div" and "date" in classes and values.get("title"):
|
||||
self.current["timestamp"] = _timestamp(values["title"])
|
||||
if tag == "a" and values.get("href"):
|
||||
href = values["href"].strip()
|
||||
if self._inside("reply_to"):
|
||||
match = re.search(r"(?:go_to_message|message)(\d+)", href)
|
||||
if match:
|
||||
self.current["reply_to"] = match.group(1)
|
||||
elif href and href not in self.current["links"]:
|
||||
self.current["links"].append(href)
|
||||
def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
||||
self.handle_starttag(tag, attrs)
|
||||
if tag not in VOID_TAGS:
|
||||
self.handle_endtag(tag)
|
||||
|
||||
def handle_data(self, data: str) -> None:
|
||||
if self.current is None:
|
||||
if self._inside("page_header") and self._inside("text"):
|
||||
self.title_chunks.append(data)
|
||||
return
|
||||
if self._inside("from_name") and not self._inside("forwarded"):
|
||||
self.current["sender"].append(data)
|
||||
elif self._inside("text"):
|
||||
self.current["text"].append(data)
|
||||
|
||||
def handle_endtag(self, tag: str) -> None:
|
||||
if not self.stack:
|
||||
return
|
||||
closing_message = tag == "div" and self.message_depth == len(self.stack) - 1
|
||||
self.stack.pop()
|
||||
if closing_message:
|
||||
self._finish_message()
|
||||
|
||||
def _finish_message(self) -> None:
|
||||
assert self.current is not None
|
||||
sender = _clean_text(self.current["sender"]) or self.last_sender
|
||||
timestamp = self.current["timestamp"]
|
||||
if not sender:
|
||||
raise ExportError(f"message {self.current['message_id']} has no sender and no joined-message predecessor")
|
||||
if not timestamp:
|
||||
raise ExportError(f"message {self.current['message_id']} has no timestamp")
|
||||
self.last_sender = sender
|
||||
self.messages.append(
|
||||
TelegramMessage(
|
||||
message_id=self.current["message_id"],
|
||||
timestamp=timestamp,
|
||||
sender=sender,
|
||||
reply_to=self.current["reply_to"],
|
||||
text=_clean_text(self.current["text"]),
|
||||
links=tuple(self.current["links"]),
|
||||
)
|
||||
)
|
||||
self.current = None
|
||||
self.message_depth = None
|
||||
|
||||
@property
|
||||
def chat_title(self) -> str:
|
||||
return _clean_text(self.title_chunks)
|
||||
|
||||
|
||||
def parse_export(path: Path) -> tuple[str, list[TelegramMessage]]:
|
||||
try:
|
||||
raw = path.read_text(encoding="utf-8", errors="strict")
|
||||
except (OSError, UnicodeDecodeError) as exc:
|
||||
raise ExportError(f"could not read strict UTF-8 Telegram export: {exc}") from exc
|
||||
parser = TelegramHtmlParser()
|
||||
try:
|
||||
parser.feed(raw)
|
||||
parser.close()
|
||||
except (ValueError, AssertionError) as exc:
|
||||
raise ExportError(f"could not parse Telegram export: {exc}") from exc
|
||||
if parser.current is not None:
|
||||
raise ExportError("Telegram export ended inside a message")
|
||||
if not parser.messages:
|
||||
raise ExportError("Telegram export contains no default messages")
|
||||
return parser.chat_title, parser.messages
|
||||
|
||||
|
||||
def redact_secret_values(value: str) -> tuple[str, int]:
|
||||
count = 0
|
||||
|
||||
def replacement(match: re.Match[str]) -> str:
|
||||
nonlocal count
|
||||
count += 1
|
||||
opening = match.group(2)
|
||||
closing = match.group(4) if match.group(4) == opening else opening
|
||||
return f"{match.group(1)}{opening}[REDACTED_SECRET]{closing}"
|
||||
|
||||
return SECRET_VALUE_RE.sub(replacement, value), count
|
||||
|
||||
|
||||
def _redact_messages(messages: list[TelegramMessage]) -> tuple[list[TelegramMessage], list[str], int]:
|
||||
redacted = []
|
||||
affected_ids = []
|
||||
value_count = 0
|
||||
for message in messages:
|
||||
text, text_count = redact_secret_values(message.text)
|
||||
links = []
|
||||
link_count = 0
|
||||
for link in message.links:
|
||||
safe_link, count = redact_secret_values(link)
|
||||
links.append(safe_link)
|
||||
link_count += count
|
||||
count = text_count + link_count
|
||||
if count:
|
||||
affected_ids.append(message.message_id)
|
||||
value_count += count
|
||||
redacted.append(
|
||||
TelegramMessage(
|
||||
message_id=message.message_id,
|
||||
timestamp=message.timestamp,
|
||||
sender=message.sender,
|
||||
reply_to=message.reply_to,
|
||||
text=text,
|
||||
links=tuple(links),
|
||||
)
|
||||
)
|
||||
return redacted, affected_ids, value_count
|
||||
|
||||
|
||||
def _format_message(message: TelegramMessage, source_sha256: str) -> str:
|
||||
metadata = {
|
||||
"id": message.message_id,
|
||||
"reply_to": message.reply_to,
|
||||
"sender": message.sender,
|
||||
"source_ref": f"telegram-export:sha256:{source_sha256}#message={message.message_id}",
|
||||
"timestamp": message.timestamp,
|
||||
}
|
||||
parts = [json.dumps(metadata, ensure_ascii=False, separators=(",", ":"), sort_keys=True)]
|
||||
parts.append(message.text or "[attachment-only message]")
|
||||
if message.links:
|
||||
parts.append("Links:\n" + "\n".join(f"- {link}" for link in message.links))
|
||||
return "\n".join(parts).strip() + "\n"
|
||||
|
||||
|
||||
def _split_messages(messages: list[TelegramMessage], source_sha256: str, max_chars: int) -> list[tuple[str, ...]]:
|
||||
if max_chars < 1_000 or max_chars > 110_000:
|
||||
raise ExportError("max segment characters must be between 1000 and 110000")
|
||||
segments: list[list[str]] = []
|
||||
current: list[str] = []
|
||||
current_chars = 0
|
||||
for message in messages:
|
||||
rendered = _format_message(message, source_sha256)
|
||||
if len(rendered) > max_chars:
|
||||
raise ExportError(f"message {message.message_id} exceeds the segment character limit")
|
||||
separator_chars = 1 if current else 0
|
||||
if current and current_chars + separator_chars + len(rendered) > max_chars:
|
||||
segments.append(current)
|
||||
current = []
|
||||
current_chars = 0
|
||||
separator_chars = 0
|
||||
current.append(rendered)
|
||||
current_chars += separator_chars + len(rendered)
|
||||
if current:
|
||||
segments.append(current)
|
||||
return [tuple(segment) for segment in segments]
|
||||
|
||||
|
||||
def _attachment_relative_path(href: str) -> PurePosixPath | None:
|
||||
parsed = urlsplit(href)
|
||||
if parsed.scheme or parsed.netloc or not parsed.path:
|
||||
return None
|
||||
relative = PurePosixPath(unquote(parsed.path))
|
||||
if not relative.parts or relative.parts[0] not in ATTACHMENT_PREFIXES:
|
||||
return None
|
||||
if relative.is_absolute() or any(part in {"", ".", ".."} for part in relative.parts):
|
||||
raise ExportError(f"unsafe Telegram attachment path: {href}")
|
||||
return relative
|
||||
|
||||
|
||||
def _attachment_inventory(export_root: Path, messages: list[TelegramMessage]) -> list[dict[str, Any]]:
|
||||
references: dict[str, set[str]] = {}
|
||||
for message in messages:
|
||||
for href in message.links:
|
||||
relative = _attachment_relative_path(href)
|
||||
if relative is not None:
|
||||
references.setdefault(relative.as_posix(), set()).add(message.message_id)
|
||||
inventory = []
|
||||
root = export_root.resolve()
|
||||
for relative_text, message_ids in sorted(references.items()):
|
||||
path = export_root / relative_text
|
||||
try:
|
||||
resolved = path.resolve(strict=True)
|
||||
except OSError as exc:
|
||||
raise ExportError(f"referenced Telegram attachment is missing: {relative_text}") from exc
|
||||
if root not in resolved.parents or not resolved.is_file() or resolved.is_symlink():
|
||||
raise ExportError(f"unsafe Telegram attachment target: {relative_text}")
|
||||
data = resolved.read_bytes()
|
||||
inventory.append(
|
||||
{
|
||||
"bytes": len(data),
|
||||
"message_ids": sorted(message_ids, key=int),
|
||||
"path": relative_text,
|
||||
"sha256": sha256_bytes(data),
|
||||
}
|
||||
)
|
||||
return inventory
|
||||
|
||||
|
||||
def prepare(export_path: Path, output_dir: Path, max_segment_chars: int) -> dict[str, Any]:
|
||||
if not export_path.is_file() or export_path.is_symlink():
|
||||
raise ExportError("export path must be a regular, non-symlink file")
|
||||
source = export_path.read_bytes()
|
||||
source_sha256 = sha256_bytes(source)
|
||||
title, messages = parse_export(export_path)
|
||||
messages, secret_message_ids, secret_value_count = _redact_messages(messages)
|
||||
segments = _split_messages(messages, source_sha256, max_segment_chars)
|
||||
attachments = _attachment_inventory(export_path.parent, messages)
|
||||
if output_dir.exists():
|
||||
raise ExportError("output directory already exists; choose a new private path")
|
||||
output_dir.mkdir(mode=0o700, parents=True)
|
||||
output_dir.chmod(0o700)
|
||||
segment_rows = []
|
||||
offset = 0
|
||||
for index, rendered_messages in enumerate(segments, start=1):
|
||||
filename = f"segment-{index:04d}.txt"
|
||||
text = "\n".join(rendered_messages)
|
||||
data = text.encode("utf-8")
|
||||
path = output_dir / filename
|
||||
path.write_bytes(data)
|
||||
path.chmod(0o600)
|
||||
count = len(rendered_messages)
|
||||
selected = messages[offset : offset + count]
|
||||
offset += count
|
||||
segment_rows.append(
|
||||
{
|
||||
"bytes": len(data),
|
||||
"characters": len(text),
|
||||
"first_message_id": selected[0].message_id,
|
||||
"last_message_id": selected[-1].message_id,
|
||||
"message_count": count,
|
||||
"path": filename,
|
||||
"sha256": sha256_bytes(data),
|
||||
"source_identity": f"telegram-export:sha256:{source_sha256}#segment={index:04d}",
|
||||
}
|
||||
)
|
||||
corpus_binding = {
|
||||
"attachments": attachments,
|
||||
"segments": segment_rows,
|
||||
"source_html_sha256": source_sha256,
|
||||
}
|
||||
manifest = {
|
||||
"schema": SCHEMA,
|
||||
"parser_version": PARSER_VERSION,
|
||||
"source": {
|
||||
"bytes": len(source),
|
||||
"chat_title": title,
|
||||
"filename": export_path.name,
|
||||
"sha256": source_sha256,
|
||||
},
|
||||
"bounds": {"max_segment_characters": max_segment_chars},
|
||||
"counts": {
|
||||
"attachments": len(attachments),
|
||||
"messages": len(messages),
|
||||
"participants": len({message.sender for message in messages}),
|
||||
"segments": len(segment_rows),
|
||||
},
|
||||
"participants": sorted({message.sender for message in messages}),
|
||||
"time_range": {"first": messages[0].timestamp, "last": messages[-1].timestamp},
|
||||
"secret_redaction": {
|
||||
"message_count": len(secret_message_ids),
|
||||
"message_ids": secret_message_ids,
|
||||
"value_count": secret_value_count,
|
||||
},
|
||||
"segments": segment_rows,
|
||||
"attachments": attachments,
|
||||
"corpus_sha256": sha256_bytes(canonical_json_bytes(corpus_binding)),
|
||||
"safety": {
|
||||
"database_write_performed": False,
|
||||
"model_call_performed": False,
|
||||
"message_text_in_manifest": False,
|
||||
"output_files_private": True,
|
||||
"secret_values_removed_before_segments": True,
|
||||
},
|
||||
}
|
||||
manifest_path = output_dir / "corpus-manifest.json"
|
||||
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
manifest_path.chmod(0o600)
|
||||
return manifest
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--export", required=True, type=Path)
|
||||
parser.add_argument("--output-dir", required=True, type=Path)
|
||||
parser.add_argument("--max-segment-chars", default=DEFAULT_MAX_SEGMENT_CHARS, type=int)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
try:
|
||||
manifest = prepare(args.export, args.output_dir, args.max_segment_chars)
|
||||
except ExportError as exc:
|
||||
print(json.dumps({"schema": SCHEMA, "status": "error", "error": str(exc)}, sort_keys=True))
|
||||
return 2
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"schema": SCHEMA,
|
||||
"status": "prepared",
|
||||
"messages": manifest["counts"]["messages"],
|
||||
"segments": manifest["counts"]["segments"],
|
||||
"corpus_sha256": manifest["corpus_sha256"],
|
||||
},
|
||||
sort_keys=True,
|
||||
)
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
124
tests/test_prepare_telegram_export_corpus.py
Normal file
124
tests/test_prepare_telegram_export_corpus.py
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import stat
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SCRIPT = ROOT / "scripts" / "prepare_telegram_export_corpus.py"
|
||||
|
||||
|
||||
def load_module():
|
||||
spec = importlib.util.spec_from_file_location("prepare_telegram_export_corpus", SCRIPT)
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def export_html(attachment_href: str = "files/evidence.txt") -> str:
|
||||
return f"""<!doctype html><html><body>
|
||||
<div class="page_header"><div class="text bold">Leo</div></div>
|
||||
<div class="message service" id="message-1"><div class="body details">1 July 2026</div></div>
|
||||
<div class="message default clearfix" id="message101">
|
||||
<div class="body"><div class="pull_right date details" title="01.07.2026 10:00:00 UTC+01:00">10:00</div>
|
||||
<div class="from_name">m3taversal</div><div class="text">Challenge this claim.<br>It is shallow.
|
||||
<a href="https://example.com/source">source</a></div></div>
|
||||
</div>
|
||||
<div class="message default clearfix joined" id="message102">
|
||||
<div class="body"><div class="pull_right date details" title="01.07.2026 10:01:00 UTC+01:00">10:01</div>
|
||||
<div class="forwarded body"><div class="from_name">Forwarded Person <span class="date details">now</span></div>
|
||||
<div class="text">Use the attached evidence. TWITTERAPI_IO_KEY=fixture-secret-value
|
||||
<a href="{attachment_href}">file</a></div></div></div>
|
||||
</div>
|
||||
<div class="message default clearfix" id="message103">
|
||||
<div class="body"><div class="pull_right date details" title="01.07.2026 10:02:00 UTC+01:00">10:02</div>
|
||||
<div class="from_name">Leo</div><div class="reply_to details"><a href="#go_to_message101">this message</a></div>
|
||||
<div class="text">The body is broader than its evidence.</div></div>
|
||||
</div></body></html>"""
|
||||
|
||||
|
||||
def write_export(tmp_path: Path, html: str | None = None) -> Path:
|
||||
root = tmp_path / "export"
|
||||
(root / "files").mkdir(parents=True)
|
||||
(root / "files" / "evidence.txt").write_text("bounded evidence\n", encoding="utf-8")
|
||||
path = root / "messages.html"
|
||||
path.write_text(html or export_html(), encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def test_prepare_builds_private_hash_bound_segments_without_model_or_database(tmp_path: Path) -> None:
|
||||
module = load_module()
|
||||
export = write_export(tmp_path)
|
||||
output = tmp_path / "private-corpus"
|
||||
|
||||
manifest = module.prepare(export, output, 1000)
|
||||
|
||||
assert manifest["schema"] == module.SCHEMA
|
||||
assert manifest["source"]["chat_title"] == "Leo"
|
||||
assert manifest["counts"] == {"attachments": 1, "messages": 3, "participants": 2, "segments": 1}
|
||||
assert manifest["participants"] == ["Leo", "m3taversal"]
|
||||
assert manifest["safety"] == {
|
||||
"database_write_performed": False,
|
||||
"message_text_in_manifest": False,
|
||||
"model_call_performed": False,
|
||||
"output_files_private": True,
|
||||
"secret_values_removed_before_segments": True,
|
||||
}
|
||||
segment_path = output / manifest["segments"][0]["path"]
|
||||
segment = segment_path.read_text(encoding="utf-8")
|
||||
assert "Challenge this claim.\nIt is shallow." in segment
|
||||
assert segment.count('"sender":"m3taversal"') == 2
|
||||
assert "Forwarded Person" not in manifest["participants"]
|
||||
assert "fixture-secret-value" not in segment
|
||||
assert "TWITTERAPI_IO_KEY=[REDACTED_SECRET]" in segment
|
||||
assert manifest["secret_redaction"] == {"message_count": 1, "message_ids": ["102"], "value_count": 1}
|
||||
assert '"reply_to":"101"' in segment
|
||||
assert "https://example.com/source" in segment
|
||||
assert manifest["attachments"][0]["message_ids"] == ["102"]
|
||||
assert manifest["attachments"][0]["sha256"] == module.sha256_bytes(b"bounded evidence\n")
|
||||
assert str(export.parent) not in json.dumps(manifest)
|
||||
assert stat.S_IMODE(output.stat().st_mode) == 0o700
|
||||
assert all(stat.S_IMODE(path.stat().st_mode) == 0o600 for path in output.iterdir())
|
||||
|
||||
|
||||
def test_prepare_is_deterministic_and_splits_only_at_message_boundaries(tmp_path: Path) -> None:
|
||||
module = load_module()
|
||||
export = write_export(tmp_path)
|
||||
|
||||
first = module.prepare(export, tmp_path / "first", 1000)
|
||||
second = module.prepare(export, tmp_path / "second", 1000)
|
||||
|
||||
assert first == second
|
||||
assert first["corpus_sha256"] == second["corpus_sha256"]
|
||||
assert all(row["characters"] <= 1000 for row in first["segments"])
|
||||
|
||||
|
||||
def test_unsafe_attachment_path_fails_before_output_creation(tmp_path: Path) -> None:
|
||||
module = load_module()
|
||||
export = write_export(tmp_path, export_html("files/../../outside.txt"))
|
||||
output = tmp_path / "must-not-exist"
|
||||
|
||||
with pytest.raises(module.ExportError, match="unsafe Telegram attachment path"):
|
||||
module.prepare(export, output, 1000)
|
||||
|
||||
assert not output.exists()
|
||||
|
||||
|
||||
def test_existing_output_directory_is_never_overwritten(tmp_path: Path) -> None:
|
||||
module = load_module()
|
||||
export = write_export(tmp_path)
|
||||
output = tmp_path / "existing"
|
||||
output.mkdir()
|
||||
marker = output / "keep"
|
||||
marker.write_text("unchanged", encoding="utf-8")
|
||||
|
||||
with pytest.raises(module.ExportError, match="already exists"):
|
||||
module.prepare(export, output, 1000)
|
||||
|
||||
assert marker.read_text(encoding="utf-8") == "unchanged"
|
||||
Loading…
Reference in a new issue