کشف پیوند پیش از دریافت صفحه شروع می‌شود. رشتهٔ ../api/item به‌تنهایی مقصد کامل نیست؛ باید بدانیم در کدام نشانی دیده شده است. این درس شانزده reference ساختگی را با یک نشانی پایه حل و خروجیِ قابل‌بررسی تولید می‌کند. برنامه هیچ صفحه‌ای دریافت نمی‌کند و وجود یک نشانی در خروجی، اجازهٔ خزش یا قابل‌اعتمادبودن مقصد را ثابت نمی‌کند.

۱. نشانی پایه و reference را جدا نگه داریم

پایهٔ نشانی و مرجع نسبی دو ورودی جدا هستند
تصویر مفهومی: پایهٔ نشانی و مرجع نسبی دو ورودی جدا هستند؛ بدون مقیاس عددی و مستقل از نتایج اجرای کد.

پایهٔ نمونه https://docs.example/manual/guide.html?old=1 است. فهرست ورودی شامل مسیر نسبی، مسیر آغازشده با اسلش، query، fragment و چند نشانی کامل است. این رشته‌ها را خودمان صریح وارد می‌کنیم؛ کد، HTML را تحلیل نمی‌کند، مقدار عنصر base را پیدا نمی‌کند و پیوندهای جاوااسکریپت را اجرا نمی‌کند.

بخش تعیین پایه در RFC 3986 اهمیت زمینهٔ reference را توضیح می‌دهد. در قرارداد آزمایش، انتخاب پایه مسئولیت فراخواننده است؛ برنامه برای حدس‌زدن آن به فایل یا شبکه مراجعه نمی‌کند. هر نتیجه نیز شمارهٔ ورودی خود را دارد تا بعداً روشن باشد کدام reference به آن رسیده است.

حداکثر سی‌ودو reference می‌پذیریم و طول هر رشته حداکثر ۵۱۲ نویسهٔ ASCII است. فاصله، نویسهٔ کنترلی، بک‌اسلش و درصدِ بدون دو رقم معتبر در مبنای شانزده رد می‌شوند. این زیرمجموعهٔ محدود برای آزمون روشن انتخاب شده؛ پشتیبانی از IRI فارسی یا اعتبارسنجیِ همهٔ شکل‌های URI ادعا نمی‌شود. reference تهی نیز سیاستاً رد می‌شود، هرچند استاندارد برای آن معنا دارد.

۲. مسیر نسبی، ریشه و query یک رفتار ندارند

مسیر ریشه‌ای و مسیر وابسته به پوشهٔ پایه
تصویر مفهومی: مسیر ریشه‌ای و مسیر وابسته به پوشهٔ پایه؛ بدون مقیاس عددی و مستقل از نتایج اجرای کد.

تابع urljoin در کتابخانهٔ استاندارد پایه و reference را ترکیب می‌کند. start.html کنار guide.html قرار می‌گیرد؛ نتیجه manual/start.html است. وجود نام فایل در پایه مهم است: next کنار manual/guide.html به manual/next می‌رسد، اما کنار manual/guide/ به manual/guide/next خواهد رسید. اسلش پایانی، تزئین ظاهری نیست.

reference برابر ../api/item?id=7 یک سطح از پوشهٔ manual بالا می‌رود و به مسیر api/item با query مشخص می‌رسد. در مقابل، /public/index.html از ریشه شروع می‌شود و پوشهٔ پایه را کنار می‌گذارد. این دو قاعده را با چسباندن رشته‌ها نمی‌توان درست پیاده کرد؛ به‌ویژه وقتی قطعه‌های نقطه و دو نقطه حضور دارند.

اگر reference فقط ?page=2 باشد، مسیر guide.html می‌ماند و query قبلی جایگزین می‌شود. برای #intro، مسیر و query پایه حفظ می‌شوند و فقط fragment اضافه می‌شود. این تفاوت در تحلیل خروجی مهم است: تغییر صفحهٔ پرس‌وجو با حرکت به یک بخش داخل همان سند یکسان نیست.

۳. fragment را جدا کنیم و تکرار را دقیق تعریف کنیم

قطعهٔ نشانی و یکسانی رشته‌ای نامزدها
تصویر مفهومی: قطعهٔ نشانی و یکسانی رشته‌ای نامزدها؛ بدون مقیاس عددی و مستقل از نتایج اجرای کد.

پس از حل reference و بررسی دامنهٔ قراردادی، با urldefrag fragment را حذف می‌کنیم. start.html و ./start.html#overview در نمونه به یک رشتهٔ نهایی می‌رسند؛ ورودی دوم duplicate و منشأ آن ورودی صفر ثبت می‌شود. #intro و #other نیز پس از حذف fragment به همان نشانی پایه با query قدیمی می‌رسند. ترتیب خروجی، ترتیب اولین ظهور است.

کلید تکرار فقط برابری دقیقِ رشتهٔ حل‌شده و بدون fragment است. نشانی دارای پورت صریح 443 با شکل بدون پورت ادغام نمی‌شود؛ ترتیب پارامترهای query نیز مرتب نمی‌شود. /a%2Fb و /a/b دو کلید متفاوت‌اند، همچنین %7Euser و ~user. این سیاست، برابری معناییِ همهٔ نشانی‌های وب را حل نمی‌کند.

بحث نرمال‌سازی در RFC 3986 دامنه‌ای جدا دارد. خود urljoin ممکن است اجزایی مانند مسیر نقطه‌دار را بازنویسی کند؛ پس نمی‌گوییم رشته هرگز تغییر نمی‌کند. می‌گوییم wrapper ما رمزگشایی درصد، مرتب‌سازی query یا یکسان‌سازی عمومیِ پورت و حروف نشانی را اضافه نکرده است. این مرز برای بازتولید نتیجه ضروری است.

۴. کد کامل را با پاسخ دستی و مرجع مسیر بیازماییم

مقایسهٔ حل نشانی با پاسخ‌های دستی مستقل
تصویر مفهومی: مقایسهٔ حل نشانی با پاسخ‌های دستی مستقل؛ بدون مقیاس عددی و مستقل از نتایج اجرای کد.

فایل link_discovery.py با Python 3.14.5 و بدون بستهٔ اضافی اجرا شده است. پس از urljoin، scheme باید https و hostname تحلیل‌شده دقیقاً docs.example باشد؛ userinfo پذیرفته نیست و پورت باید ضمنی یا 443 باشد. مطابق هشدار مستندات، reference کامل یا آغازشده با دو اسلش می‌تواند میزبان را عوض کند؛ بررسی خروجی بعد از ترکیب انجام می‌شود.

"""Bounded offline reference resolution; no HTML parsing, fetching or SSRF guard."""
import json
import platform
import re
from urllib.parse import urljoin, urlsplit, urldefrag

ALLOWED_HOST = 'docs.example'


def raw_reason(value):
    if type(value) is not str:
        return 'reference_type'
    if not value:
        return 'empty_reference'
    if len(value) > 512:
        return 'reference_length'
    if any(ord(char) <= 32 or ord(char) > 126 or char == '\\' for char in value):
        return 'unsupported_characters'
    if re.search(r'%(?![0-9A-Fa-f]{2})', value):
        return 'malformed_percent_escape'
    return None


def scope_reason(url):
    try:
        parts = urlsplit(url)
        port = parts.port
    except ValueError:
        return 'invalid_url_or_port'
    if parts.scheme != 'https':
        return 'scheme'
    if parts.username is not None or parts.password is not None:
        return 'userinfo'
    if parts.hostname != ALLOWED_HOST:
        return 'host'
    if port not in (None, 443):
        return 'port'
    return None


def discover(base, references):
    reason = raw_reason(base)
    if reason:
        raise ValueError('base:' + reason)
    if reason := scope_reason(base):
        raise ValueError('base_scope:' + reason)
    if type(references) is not list or len(references) > 32:
        raise ValueError('references')
    candidates, seen, decisions = [], {}, []
    for index, reference in enumerate(references):
        row = {'input_index': index, 'reference': reference}
        if reason := raw_reason(reference):
            row.update(status='rejected', reason=reason)
        else:
            try:
                absolute = urljoin(base, reference)
                reason = scope_reason(absolute)
                candidate = urldefrag(absolute).url if reason is None else None
            except ValueError:
                reason, candidate = 'invalid_url_or_port', None
            if reason:
                row.update(status='rejected', reason=reason)
            elif candidate in seen:
                row.update(status='duplicate', candidate=candidate, duplicate_of_input=seen[candidate])
            else:
                seen[candidate] = index
                candidates.append(candidate)
                row.update(status='candidate', candidate=candidate)
        decisions.append(row)
    return {'base': base, 'candidates': candidates, 'decisions': decisions, 'network_requests_made': 0}


def path_stack_oracle(reference):
    """Own narrow path-only oracle for base directory /manual, no urllib."""
    stack = [] if reference.startswith('/') else ['manual']
    for part in reference.split('/'):
        if part in ('', '.'):
            continue
        if part == '..':
            if stack:
                stack.pop()
        else:
            stack.append(part)
    path = '/' + '/'.join(stack)
    if reference.endswith('/') and path != '/':
        path += '/'
    return 'https://docs.example' + path


def rejects(call, message):
    try:
        call()
    except ValueError as error:
        return str(error) == message
    return False


def run_example():
    base = 'https://docs.example/manual/guide.html?old=1'
    references = ['start.html', './start.html#overview', '../api/item?id=7#details',
                  '/public/index.html', '?page=2', '#intro', '#other',
                  '//docs.example/api/item?id=7', 'https://docs.example:443/public/index.html',
                  'http://docs.example/public/index.html', 'https://elsewhere.example/a',
                  '//docs.example.evil/a', 'https://user@docs.example/a',
                  'https://docs.example:bad/a', '', 'bad path']
    original = list(references)
    result = discover(base, references)
    expected_candidates = ['https://docs.example/manual/start.html', 'https://docs.example/api/item?id=7',
                           'https://docs.example/public/index.html', 'https://docs.example/manual/guide.html?page=2',
                           'https://docs.example/manual/guide.html?old=1', 'https://docs.example:443/public/index.html']
    expected_statuses = ['candidate', 'duplicate', 'candidate', 'candidate', 'candidate', 'candidate',
                         'duplicate', 'duplicate', 'candidate', 'rejected', 'rejected', 'rejected',
                         'rejected', 'rejected', 'rejected', 'rejected']
    single = lambda reference, current_base=base: discover(current_base, [reference])
    percent = discover(base, ['/a%2Fb', '/a/b', '/%7Euser', '/~user'])
    queries = discover(base, ['/q?a=1&b=2', '/q?b=2&a=1'])
    rejected_inputs = [None, ' a', 'a\n', 'a\tb', 'a\\b', 'مسیر', '/bad%GG', 'x' * 513]
    reasons = [single(value)['decisions'][0]['reason'] for value in rejected_inputs]
    cases, oracle_ok = 0, True
    for prefix in ['', './', '../', '../../']:
        for suffix in ['a', 'a/../b', 'a/./b', 'a/']:
            reference = prefix + suffix
            oracle_ok &= single(reference)['candidates'] == [path_stack_oracle(reference)]
            cases += 1
    altered = discover(base, references); altered['candidates'].clear(); altered['decisions'][0]['status'] = 'changed'
    checks = {
        'hand_status_matrix_for_sixteen_references': [row['status'] for row in result['decisions']] == expected_statuses,
        'hand_six_exact_candidate_strings_in_first_order': result['candidates'] == expected_candidates,
        'file_base_and_directory_base_resolve_differently': single('next')['candidates'] == ['https://docs.example/manual/next'] and single('next', 'https://docs.example/manual/guide/')['candidates'] == ['https://docs.example/manual/guide/next'],
        'root_reference_discards_base_directory': single('/root/item')['candidates'] == ['https://docs.example/root/item'],
        'query_only_preserves_path_and_replaces_query': single('?page=2')['candidates'] == ['https://docs.example/manual/guide.html?page=2'],
        'fragment_only_preserves_base_path_and_query': single('#part')['candidates'] == ['https://docs.example/manual/guide.html?old=1'],
        'fragment_variants_deduplicate_with_provenance': result['decisions'][1]['duplicate_of_input'] == 0 and result['decisions'][6]['duplicate_of_input'] == 5 and result['decisions'][7]['duplicate_of_input'] == 2,
        'absolute_reference_can_replace_host_or_scheme': single('//elsewhere.example/a')['decisions'][0]['reason'] == 'host' and result['decisions'][9]['reason'] == 'scheme',
        'hostname_case_is_accepted_without_general_rewriting': single('https://DOCS.EXAMPLE/a')['decisions'][0]['status'] == 'candidate',
        'explicit_443_is_allowed_but_not_merged_as_string': len(discover(base, ['/p', 'https://docs.example:443/p'])['candidates']) == 2 and single('https://docs.example:444/p')['decisions'][0]['reason'] == 'port',
        'userinfo_and_similar_suffix_host_reject': result['decisions'][12]['reason'] == 'userinfo' and result['decisions'][11]['reason'] == 'host',
        'malformed_port_and_bracket_url_reject': result['decisions'][13]['reason'] == 'invalid_url_or_port' and single('https://[broken')['decisions'][0]['reason'] == 'invalid_url_or_port',
        'percent_encoded_paths_are_not_unquoted_or_merged': percent['candidates'] == ['https://docs.example/a%2Fb', 'https://docs.example/a/b', 'https://docs.example/%7Euser', 'https://docs.example/~user'],
        'query_parameter_order_is_not_canonicalized': queries['candidates'] == ['https://docs.example/q?a=1&b=2', 'https://docs.example/q?b=2&a=1'],
        'raw_type_controls_whitespace_backslash_and_percent_reject': reasons == ['reference_type', 'unsupported_characters', 'unsupported_characters', 'unsupported_characters', 'unsupported_characters', 'unsupported_characters', 'malformed_percent_escape', 'reference_length'],
        'bounded_batch_and_explicit_valid_base_contract': len(discover(base, ['a'] * 32)['decisions']) == 32 and rejects(lambda: discover(base, ['a'] * 33), 'references') and rejects(lambda: discover(base, ('a',)), 'references') and rejects(lambda: discover('relative/base', []), 'base_scope:scheme') and rejects(lambda: discover('https://elsewhere.example/', []), 'base_scope:host'),
        'caller_input_and_fresh_outputs_are_independent_no_fetch': references == original and discover(base, references) == result and result['network_requests_made'] == 0,
        'independent_narrow_path_stack_oracle': cases == 16 and bool(oracle_ok),
    }
    assert len(checks) == 18 and all(type(value) is bool and value for value in checks.values()), checks
    return {'python': platform.python_version(), 'dependencies': 'Python standard library only',
            'scope': 'Bounded offline explicit reference discovery; not HTML extraction, fetching, robots/auth, canonicalizer or SSRF protection',
            'results': {'discovery': result, 'candidate_count': len(result['candidates']),
                        'duplicate_reference_count': sum(row['status'] == 'duplicate' for row in result['decisions']),
                        'rejected_reference_count': sum(row['status'] == 'rejected' for row in result['decisions']),
                        'path_oracle_cases_inside_one_group': cases,
                        'limits': {'max_references': 32, 'max_reference_ascii_characters': 512,
                                   'allowed_host': ALLOWED_HOST, 'scheme': 'https', 'ports': 'implicit or443',
                                   'deduplication': 'exact resolved defragmented string only'}}, 'checks': checks}


if __name__ == '__main__':
    print(json.dumps(run_example(), ensure_ascii=False, indent=2))

جدول دستیِ شانزده ورودی شش نامزد، سه reference تکراری و هفت ردشدن دارد. شش نامزد به‌ترتیب شامل start، مسیر api، public، query جدید، پایهٔ بدون fragment و public با پورت صریح‌اند. ورودی api با دو اسلش، منشأ تکرارش ورودی شمارهٔ ۲ در شماره‌گذاریِ صفرمبناست؛ هر duplicate شمارهٔ ورودیِ اولین ظهور خود را نگه می‌دارد.

هجده گروه بررسی اجرا می‌شود. در یک گروه، مرجع مستقلی با پشتهٔ قطعه‌های مسیر نوشته‌ایم که urllib را فراخوانی نمی‌کند: چهار پیشوند و چهار پسوند، شانزده حالت مسیرِ محدود را می‌سازند. این مرجع فقط همان دامنهٔ مسیر را پوشش می‌دهد؛ oracle عمومیِ scheme، query یا همهٔ استاندارد نیست. شانزده حالتِ داخل یک گروه، شانزده گروه تازه محسوب نمی‌شود.

۵. نامزد دریافت را با مجوز دریافت اشتباه نگیریم

محدودهٔ میزبان مجاز با مجوز دریافت یکسان نیست
تصویر مفهومی: محدودهٔ میزبان مجاز با مجوز دریافت یکسان نیست؛ بدون مقیاس عددی و مستقل از نتایج اجرای کد.

https شرط کافی برای امن‌بودن مقصد نیست. برنامه DNS، تغییر مسیر، گواهی، محتوا یا دسترسی را بررسی نمی‌کند. مستندات امنیتِ تحلیل URL نیز تفکیک تحلیل اجزا از اعتبارسنجی را گوشزد می‌کند. فیلتر میزبان این نمونه، محافظ SSRF یا مرز امنیتیِ آمادهٔ استقرار نیست؛ فقط قرارداد دادهٔ تمرین است.

{
  "python": "3.14.5",
  "dependencies": "Python standard library only",
  "scope": "Bounded offline explicit reference discovery; not HTML extraction, fetching, robots/auth, canonicalizer or SSRF protection",
  "results": {
    "discovery": {
      "base": "https://docs.example/manual/guide.html?old=1",
      "candidates": [
        "https://docs.example/manual/start.html",
        "https://docs.example/api/item?id=7",
        "https://docs.example/public/index.html",
        "https://docs.example/manual/guide.html?page=2",
        "https://docs.example/manual/guide.html?old=1",
        "https://docs.example:443/public/index.html"
      ],
      "decisions": [
        {
          "input_index": 0,
          "reference": "start.html",
          "status": "candidate",
          "candidate": "https://docs.example/manual/start.html"
        },
        {
          "input_index": 1,
          "reference": "./start.html#overview",
          "status": "duplicate",
          "candidate": "https://docs.example/manual/start.html",
          "duplicate_of_input": 0
        },
        {
          "input_index": 2,
          "reference": "../api/item?id=7#details",
          "status": "candidate",
          "candidate": "https://docs.example/api/item?id=7"
        },
        {
          "input_index": 3,
          "reference": "/public/index.html",
          "status": "candidate",
          "candidate": "https://docs.example/public/index.html"
        },
        {
          "input_index": 4,
          "reference": "?page=2",
          "status": "candidate",
          "candidate": "https://docs.example/manual/guide.html?page=2"
        },
        {
          "input_index": 5,
          "reference": "#intro",
          "status": "candidate",
          "candidate": "https://docs.example/manual/guide.html?old=1"
        },
        {
          "input_index": 6,
          "reference": "#other",
          "status": "duplicate",
          "candidate": "https://docs.example/manual/guide.html?old=1",
          "duplicate_of_input": 5
        },
        {
          "input_index": 7,
          "reference": "//docs.example/api/item?id=7",
          "status": "duplicate",
          "candidate": "https://docs.example/api/item?id=7",
          "duplicate_of_input": 2
        },
        {
          "input_index": 8,
          "reference": "https://docs.example:443/public/index.html",
          "status": "candidate",
          "candidate": "https://docs.example:443/public/index.html"
        },
        {
          "input_index": 9,
          "reference": "http://docs.example/public/index.html",
          "status": "rejected",
          "reason": "scheme"
        },
        {
          "input_index": 10,
          "reference": "https://elsewhere.example/a",
          "status": "rejected",
          "reason": "host"
        },
        {
          "input_index": 11,
          "reference": "//docs.example.evil/a",
          "status": "rejected",
          "reason": "host"
        },
        {
          "input_index": 12,
          "reference": "https://user@docs.example/a",
          "status": "rejected",
          "reason": "userinfo"
        },
        {
          "input_index": 13,
          "reference": "https://docs.example:bad/a",
          "status": "rejected",
          "reason": "invalid_url_or_port"
        },
        {
          "input_index": 14,
          "reference": "",
          "status": "rejected",
          "reason": "empty_reference"
        },
        {
          "input_index": 15,
          "reference": "bad path",
          "status": "rejected",
          "reason": "unsupported_characters"
        }
      ],
      "network_requests_made": 0
    },
    "candidate_count": 6,
    "duplicate_reference_count": 3,
    "rejected_reference_count": 7,
    "path_oracle_cases_inside_one_group": 16,
    "limits": {
      "max_references": 32,
      "max_reference_ascii_characters": 512,
      "allowed_host": "docs.example",
      "scheme": "https",
      "ports": "implicit or443",
      "deduplication": "exact resolved defragmented string only"
    }
  },
  "checks": {
    "hand_status_matrix_for_sixteen_references": true,
    "hand_six_exact_candidate_strings_in_first_order": true,
    "file_base_and_directory_base_resolve_differently": true,
    "root_reference_discards_base_directory": true,
    "query_only_preserves_path_and_replaces_query": true,
    "fragment_only_preserves_base_path_and_query": true,
    "fragment_variants_deduplicate_with_provenance": true,
    "absolute_reference_can_replace_host_or_scheme": true,
    "hostname_case_is_accepted_without_general_rewriting": true,
    "explicit_443_is_allowed_but_not_merged_as_string": true,
    "userinfo_and_similar_suffix_host_reject": true,
    "malformed_port_and_bracket_url_reject": true,
    "percent_encoded_paths_are_not_unquoted_or_merged": true,
    "query_parameter_order_is_not_canonicalized": true,
    "raw_type_controls_whitespace_backslash_and_percent_reject": true,
    "bounded_batch_and_explicit_valid_base_contract": true,
    "caller_input_and_fresh_outputs_are_independent_no_fetch": true,
    "independent_narrow_path_stack_oracle": true
  }
}

پس از این مرحله، آزمون آفلاین robots.txt موضوعِ دیگری برای تصمیم خزش دارد؛ آن درس ورودی نسبی را حل نمی‌کرد. همچنین کشف نشانی با ساخت نمایهٔ معکوس تفاوت دارد: هنوز سندی دریافت یا واژه‌ای نمایه نشده است. مقدار network_requests_made صفر است چون برنامه هیچ تابع دریافت ندارد، نه اینکه سامانهٔ پایش شبکه‌ای ساخته باشیم.

پرسش اول: چرا next با دو پایهٔ نزدیک نتیجهٔ متفاوت می‌دهد؟ پاسخ: پایهٔ بدون اسلش پایانی، آخرین جزء را نام فایل می‌گیرد؛ پایهٔ دارای اسلش، آن جزء را در پوشهٔ مقصد نگه می‌دارد.

پرسش دوم: آیا دو fragment حتماً دو نامزد می‌سازند؟ پاسخ: خیر؛ در قرارداد ما fragment حذف می‌شود و #intro و #other به یک رشته می‌رسند. منشأ اولین ورودی همچنان ثبت است.

پرسش سوم: چرا پورت صریح 443 نامزد جدا دارد؟ پاسخ: فیلتر آن را مجاز می‌داند، اما کلید تکرار برابری دقیقِ رشته است؛ این درس نرمال‌ساز عمومی نیست.

پرسش چهارم: آیا شش نامزد یعنی شش صفحهٔ دریافت‌شده؟ پاسخ: خیر؛ هیچ درخواست شبکه‌ای انجام نشده است. منابع در ۲۴ مهر ۱۴۰۵ خوانده شدند؛ RFC مربوط به ژانویهٔ ۲۰۰۵ است. صفحهٔ مرجع Python 3.14.8 با اجرای ثبت‌شدهٔ 3.14.5 جدا گزارش می‌شود و این درس، خبر تازه یا محصول خزنده نیست.

منابع و مطالعهٔ بیشتر

بررسی منابع: ۲۴ مهر ۱۴۰۵. تحلیل‌ها و پیشنهادهای اجرایی، جمع‌بندی نویسنده‌اند.