첨부 목록에 파일 이름이 나타나면 일을 끝냈다고 말하기 쉽다. 하지만 다른 파일이 선택됐을 수도 있고 선택한 뒤 내용이 바뀌었을 수도 있다. 북극성에서 이 경계를 다룬 코드를 바탕으로, 무엇을 확인했는지가 결과 필드에 남는 작은 예제를 만들었다.

실제 경로 대신 합성 바이트를 받는다

예제는 방문자의 파일을 열지 않는다. 호출자가 직접 전달한 bytes만 받아 메모리에 보관한다. 파일 이름은 경로 구분자와 예약 장치명 등을 거절하는 짧은 basename이며 저장 위치로 쓰이지 않는다. 자료 한 개는 64KiB 이하, 같은 명시 범위에는 최대 스무 개라는 작은 실험 조건을 뒀다.

원래 제품의 TaskFiles는 실제 경로·크기·수정 시각을 등록하고 실행 시 프로필과 작업에 허용된 ID를 대조한다. 여기서는 profile과 project를 함께 받는 Scope로 범위를 더 직접 표현했다. 원본의 파일 크기 한도나 권한 구현을 그대로 옮겼다는 뜻은 아니다. 운영 연결 전에는 이 차이를 먼저 읽어야 한다.

핸들은 내용과 선택 수명을 함께 가리킨다

같은 범위에서 이름과 내용이 그대로인 자료는 핸들을 재사용한다. 다른 내용은 다른 선택으로 등록한다. describe는 이번 요청에 허용된 핸들만 읽고 이름·크기·SHA256을 돌려준다. ID를 알고 있다는 사실만으로 다른 프로젝트나 다른 프로필의 선택을 읽을 수 있게 만들지 않았다.

change_synthetic_bytes는 선택 후 자료가 바뀐 상황을 합성한다. 길이가 같더라도 원래 bytes와 달라졌으면 거절한다. revoke는 선택 수명을 끝낸다. 이미 받아 둔 ID와 허용 목록이 있어도 철회 뒤에는 읽히지 않는다. 다만 호출자가 만든 Scope 자체를 로그인된 사람의 신원으로 인증하는 기능은 이 예제에 없다.

선택 정보가 맞아도 서버 저장은 아직 모른다

compare_selection은 관찰했다고 선언한 이름·크기의 목록을 순서까지 대조한다. 예상 파일 두 개를 거꾸로 보냈거나 이름은 같아도 크기가 다르면 selection_verified가 False다. 서버 저장을 주장하는 추가 필드를 끼워 넣어 비교 결과를 승격시키는 입력도 받지 않는다.

이름과 크기가 맞아도 같은 크기의 다른 내용일 수 있다. 그래서 일치한 결과에도 server_saved_verified와 observation_authenticated는 항상 False다. 이 함수는 실제 브라우저를 읽거나 첨부를 전송하지 않는다. 운영에서는 현재 페이지의 선택 확인과 서버가 반환한 결과를 서로 다른 단계로 연결해야 한다.

코드와 구현 살펴보기

독립 합성 전체 구현

"""Independent synthetic byte/selection receipts; no filesystem or network access."""
from dataclasses import dataclass
from hashlib import sha256
from uuid import uuid4
import re

MAX_BYTES = 65536


def payload(value):
    if type(value) is not bytes or len(value) > MAX_BYTES:
        raise ValueError('BYTES_REQUIRED_WITHIN_64_KIB')
    return value


def filename(value):
    if (type(value) is not str or not value or len(value) > 140
            or value != value.strip() or value.endswith('.')
            or value in ('.', '..') or re.search(r'[<>:"/\\|?*\x00-\x1f]', value)
            or re.match(r'^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)', value, re.I)):
        raise ValueError('BASENAME_REQUIRED')
    return value


@dataclass(frozen=True)
class Scope:
    profile: str
    project: str

    def __post_init__(self):
        if any(type(x) is not str or not x.strip() or len(x) > 80
               for x in (self.profile, self.project)):
            raise ValueError('SCOPE_REQUIRED')


class Selection:
    """Immutable selected snapshots plus mutable synthetic current bytes."""
    def __init__(self):
        self._rows = {}

    def add(self, scope, name, data):
        if type(scope) is not Scope:
            raise ValueError('SCOPE_REQUIRED')
        name, data = filename(name), payload(data)
        for key, row in self._rows.items():
            if row['scope'] == scope and row['name'] == name and row['original'] == data and row['current'] == data:
                return key
        if sum(r['scope'] == scope for r in self._rows.values()) >= 20:
            raise ValueError('SELECTION_LIMIT')
        key = str(uuid4())
        self._rows[key] = {'scope': scope, 'name': name, 'original': data, 'current': data}
        return key

    def _row(self, scope, handle):
        row = self._rows.get(handle)
        if type(scope) is not Scope or row is None or row['scope'] != scope:
            raise ValueError('HANDLE_DENIED')
        return row

    def change_synthetic_bytes(self, scope, handle, data):
        row = self._row(scope, handle)
        row['current'] = payload(data)

    def revoke(self, scope, handle):
        self._row(scope, handle)
        del self._rows[handle]

    def describe(self, scope, allowed, handles):
        if type(allowed) is not list or type(handles) is not list or not handles:
            raise ValueError('NONEMPTY_HANDLE_LIST_REQUIRED')
        if any(type(x) is not str for x in allowed + handles) or len(set(handles)) != len(handles):
            raise ValueError('DISTINCT_STRING_HANDLES_REQUIRED')
        result = []
        for key in handles:
            if key not in allowed:
                raise ValueError('HANDLE_DENIED')
            row = self._row(scope, key)
            if row['current'] != row['original']:
                raise ValueError('SELECTED_BYTES_CHANGED')
            data = row['current']
            result.append({'id': key, 'name': row['name'], 'size': len(data), 'sha256': sha256(data).hexdigest()})
        return result

    def compare_selection(self, scope, allowed, handles, observed):
        expected = self.describe(scope, allowed, handles)
        if type(observed) is not list or any(type(x) is not dict or set(x) != {'name', 'size'}
                or type(x['name']) is not str or type(x['size']) is not int or x['size'] < 0 for x in observed):
            raise ValueError('OBSERVED_NAME_SIZE_LIST_REQUIRED')
        matched = observed == [{'name': x['name'], 'size': x['size']} for x in expected]
        return {'selection_verified': matched, 'server_saved_verified': False,
                'observation_authenticated': False, 'files': expected,
                'reason': 'names_and_sizes_match' if matched else 'selection_mismatch'}


def compare_download_bytes(data, received, completed, expected_sha256=None):
    """Hash supplied bytes, not a remote URL, path, or invented saved file."""
    data = payload(data)
    if type(received) is not int or received < 0 or type(completed) is not bool:
        raise ValueError('DECLARED_TRANSFER_FIELDS_REQUIRED')
    if expected_sha256 is not None and (type(expected_sha256) is not str or not re.fullmatch('[0-9a-f]{64}', expected_sha256)):
        raise ValueError('LOWERCASE_SHA256_REQUIRED')
    digest = sha256(data).hexdigest()
    reason = ('not_completed' if not completed else 'size_mismatch' if received != len(data)
              else 'digest_mismatch' if expected_sha256 is not None and expected_sha256 != digest
              else 'supplied_bytes_match')
    return {'verified': reason == 'supplied_bytes_match', 'reason': reason, 'size': len(data),
            'sha256': digest, 'expected_digest_checked': expected_sha256 is not None,
            'network_origin_verified': False, 'power_loss_durability_verified': False}

다운로드 비교는 전달받은 바이트까지만 말한다

compare_download_bytes는 실제로 이 함수에 들어온 bytes의 길이와 SHA256을 계산한다. 완료 선언·수신량·선택적인 예상 해시를 비교하고 미완료, 크기 불일치, 해시 불일치를 나눠 반환한다. 같은 크기의 다른 문자열을 넣은 예제는 해시 불일치로 남는다.

verified는 이 입력 비교가 맞았다는 뜻이다. 원격 서버에서 받았다는 출처, 동영상 재생 가능 여부, 전원 차단 뒤 보존을 증명하지 않는다. 특히 예상 해시가 없으면 새로 계산한 지문만 있을 뿐 원본과의 일치 비교는 없으므로 expected_digest_checked를 별도 필드로 보여 준다.

성공보다 거절할 입력을 먼저 시험했다

다른 프로필·프로젝트, 허용하지 않은 ID, 철회된 ID, 선택 뒤 바뀐 내용, 잘못된 이름과 순서, 크기 대신 들어온 boolean을 시험한다. 선택 수 한도를 넘겨도 기존 자료는 남고, 결과 사본을 수정해도 내부 선택이 바뀌지 않는지 확인했다. 빈 파일의 완료 비교도 따로 포함했다.

독립 원본의 36개 시험과 ZIP에서 추출한 같은 36개 시험을 실행한다. 다섯 기록은 이 함수들을 로컬에서 호출해 만든 것이며 아래 화면은 그 저장 기록만 읽는다. 원래 북극성의 네이티브 업로드나 다운로드를 실행한 결과로 표시하지 않았다. 누적 시험 수와 이번 새 시험도 구분해 기록한다.

합성 로컬 기록의 읽기 뷰어

운영에 연결할 때 남은 책임도 공개한다

이 코드는 파일 선택창, 실제 경로 읽기, 비동기 전송 직전의 재검사, 파일 쓰기와 동기화, 네트워크 재시도, 로그인과 영속 원장을 구현하지 않는다. 원래 제품에서 따로 다룬 책임들이다. 작은 예제에서 생략한 부분을 연결한 것처럼 보여 주면 오히려 재사용하는 사람이 위험한 가정을 하게 된다.

코드·시험·기록 생성기와 설명은 수정하고 재사용할 수 있다. 파일 이름, 선택 수명, 실제 바이트, 원격 저장, 디스크 내구성을 각자의 근거로 다루는 출발점으로 공유한다. 운영 성공을 한 개의 녹색 배지에 몰아넣기보다 어느 단계가 확인됐는지 읽히게 하는 것이 내가 이 모듈에 남긴 기준이다.

코드·시험·실행 기록 ZIP 받기
Python 표준 라이브러리 · 합성 자료 · 수정과 재사용 가능