본문 바로가기
C.W.K.
Lesson 02 of 04 · published

혈통은 옮아

~14 min · 계보, 콘텐츠 주소, 그래프 폐포, SHA-256

Level 0스케치
0 XP0/43 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete

사진에 대상이 붙는 세 갈래

지난 레슨의 가시성 검사는 대상을 물어. 이번엔 그 대상이 어디서 오는지 볼 거야. 검사는 손에 쥔 목록만큼만 믿을 수 있으니까. Avatars의 에셋 행은 전부 subjects 목록과 references 목록을 들고 있고, 이걸 채우는 길은 세 갈래야.

  1. 올릴 때 밝힌 대상. Studio.upload는 올린 쪽이 지목한 대상을 행위자와 함께 검사하고, 새 에셋에 묶어.
  2. 태어날 때 물려받는 대상. 생성을 계획하면 작업마다 대상이 정해져. 지정한 대상에, 입력마다 혈통에 든 대상을 전부 더한 거야. 워커가 결과를 저장할 때 그 대상을 그대로 적고, 입력의 id는 references에 남겨.
  3. 똑같은 바이트에서 물려받는 대상. save_asset은 새 바이트를 해시해서 SHA-256이 같은 에셋을 전부 찾아. 그 에셋들의 대상은 새 행의 대상에 합쳐지고, 그 id는 references에 들어가.

세 번째 갈래엔 조심할 구석이 하나 있어. 어떤 행위자가 보호받는 사진의 정확한 사본을 올리면, 행이 먼저 써져. 물려받은 보호 대상까지 달고서. 그러고 나서 save_upload가 새 에셋의 대상을 그 행위자 기준으로 검사하고 403을 내. 사본은 저장되고, 보호받고, 그걸 가져온 소울은 정작 못 봐.

읽을 때마다 다시 따라가

태어날 때 대상을 찍어 두는 건 싸고, 기록을 열었을 때 정직해. 그래도 Avatars는 그 도장에 기대지 않아. asset_lineages는 혈통을 물을 때마다 스토어의 스냅숏 하나로 답을 새로 만들어. 모든 에셋을 해시로 색인한 다음, 요청받은 id에서 출발해 따라가. 이 에셋의 대상을 모으고, references를 따라가고, 바이트가 같은 에셋도 전부 따라가. 새로 나오는 게 없을 때까지 되풀이해. 그 읽기에선 이 결과가 행에 적힌 대상 목록을 대신해.

이렇게 따라가면 도장이 못 잡는 걸 잡아. 어떤 사본이 대상 하나 없이 먼저 올라왔다고 해 봐. 몇 주 뒤에 같은 바이트가 보호받는 소울 태그를 달고 다시 들어와. 첫 행의 도장은 여전히 비어 있어. 그런데 첫 행에서 출발하면 같은 해시를 타고 두 번째 행에 닿아서, 보호받는 소울을 들고 돌아와. 같은 바이트로 이어진 선에선 전염이 양쪽으로 흘러.

추적은 대상을 모으기만 해. 그중 누가 보호받는지는 그 순간 피파의 레지스트리에 물어. 그래서 어떤 소울이 슈퍼가 되면, 이미 그 소울에서 나온 사진은 다음 읽기부터 전부 보호받아. 마이그레이션도, 백필도 없어. 백엔드 테스트가 정확히 그걸 확인해. 마지막 두 줄이 공개였던 소울을 보호로 바꾸고, 그 소울이 담긴 이미지가 더는 밖으로 못 나간다고 단언해.

이름 바꾸기로는 안 돼

트랙 2에서 봤듯이 디스크의 원본 파일 이름은 자기 SHA-256이야. 스튜디오에 보이는 라벨은 200자에서 잘리는 자유 텍스트고, 신원과는 상관이 없어. 이름을 바꾸면 라벨만 바뀌어. 파일을 다른 폴더로 복사하면 아무것도 안 바뀌고. 보관해도 소용없어. 내보내기 검사는 혈통을 읽고, 보관 플래그는 일부러 안 봐. 아키텍처 문서는 이 규칙을 한 줄로 적어. 이름 바꾸기도, 프리셋 변경도, 복사도 혈통을 지우지 못한다고.

같은 바이트 규칙이 못 보는 것

save_asset에서 해시를 찾는 줄 위의 주석은 아래 줄이 지킬 수 있는 것보다 많이 약속해. 파일 이름이나 내장 메타데이터를 벗겨서 보호받는 아바타를 세탁할 수 없다고 하거든. 파일 이름 쪽은 맞아. 메타데이터 쪽은 틀려. 이유는 코드가 말해 줘. 찾기는 바이트 전체의 SHA-256을 비교해. EXIF를 벗기면 다른 바이트가 써져. 다시 인코딩해도, 크기를 줄여도, 잘라도, 스크린숏을 찍어도 마찬가지야. 다 원본과 더는 안 맞는 다이제스트가 나와. 그러니 원본이 그 사본을 지켜 줄 수 없어. 그 새 바이트가 전에 따로 저장된 적이 없다면, 그러니까 전에 누가 올린 적도 스튜디오가 만든 적도 없다면, 사본은 낯선 사진으로 들어오고 올린 쪽이 밝힌 대상에만 묶여. 저장된 적이 있다면 그 줄과 맞아서 그 줄이 지닌 대상을 그대로 물려받아.

이 길 어디에도 얼굴을 들여다보는 곳은 없어. 보장은 딱 두 가지야. 똑같은 바이트는 대상을 나눠 갖고, 스튜디오가 만든 건 재료의 혈통을 물려받아. 눈으로 보기에만 비슷한 사본은 어느 쪽에도 안 걸려. 그러니까 규칙도 딱 그만큼만 가르쳐야 해. 그 이상이 필요할 때 정직하게 꺼낼 다음 도구는 지각 해시야. Avatars엔 그게 없고. 이 주석은 챙겨 둘 만한 습관 하나를 일깨워 줘. 위에 적힌 주석 말고 코드 줄을 읽으라는 거야.

폐포가 지켜지는 두 군데 더

보호받는 이미지는 공개 소울의 레퍼런스가 될 수 없어. save_profile은 프로필과 저장된 룩에 든 에셋을 전부 풀고, 그 대상을 모두 물어. 대상 소울이 공개인데 대상 중에 슈퍼가 하나라도 있으면 403으로 거절해. 발행 쪽에선 피파의 publish가 프레임마다 대상을 모아서, 공개 소울 밑에 보호 대상이 섞인 세트를 거절해. 보호받는 소울 자기 세트는 피파로 평소대로 발행돼. 막히는 건 공개 소울의 사본뿐이야.

원칙: 간선을 저장하고, 물을 때 폐포를 계산하고, 지금 정책에 물어. 찍어 둔 답은 낡지만, 그때그때 따라가서 얻은 답은 낡을 수가 없어.

Code

cwkAvatars, backend/store.py: save_asset은 똑같은 바이트의 대상을 전부 물려받아 (원문 그대로, 164-169행)·python
        digest = hashlib.sha256(data).hexdigest()
        # Reimports retain every known subject; an upload cannot launder a
        # protected avatar by stripping its filename or embedded metadata.
        known = [a for a in self.list("asset") if a["sha256"] == digest]
        subjects = sorted(set(subjects).union(*(set(a["subjects"]) for a in known)))
        refs = list(dict.fromkeys([*(references or []), *(a["id"] for a in known)]))
cwkAvatars, backend/store.py: asset_lineages, references와 똑같은 바이트를 따라가는 폐포 (원문 그대로, 219-241행)·python
    def asset_lineages(self, ids: list[str] | None = None) -> dict[str, dict]:
        """Resolve current ancestry from one snapshot, never a stale policy cache."""
        assets = {a["id"]: a for a in self.list("asset")}
        requested = list(dict.fromkeys(ids)) if ids is not None else list(assets)
        if any(id not in assets for id in requested):
            raise StudioError("Record not found", 404)
        hashes = {}
        for a in assets.values():
            hashes.setdefault(a["sha256"], []).append(a["id"])
        resolved = {}
        for id in requested:
            subjects, seen, queue = set(), set(), [id]
            while queue:
                current = queue.pop()
                if current in seen or current not in assets:
                    continue
                seen.add(current)
                a = assets[current]
                subjects.update(a["subjects"])
                queue.extend(a.get("references", []))
                queue.extend(hashes[a["sha256"]])
            resolved[id] = {**assets[id], "subjects": sorted(subjects)}
        return resolved
cwkAvatars, backend/tests/test_studio.py: 보호 업로드 하나, 그리고 이름만 바꾸고 대상 없이 다시 올린 같은 바이트 (원문 그대로, 120-127행)·python
    protected = await studio.upload(image("black"), "Private reference", ["secret"])
    with pytest.raises(StudioError):
        await studio.save_profile("pippa", ReferenceProfile(version=profile["version"], mode="override", assets=[protected["id"]]))
    reupload = await studio.upload(image("black"), "Renamed reference", [])
    assert "secret" in reupload["subjects"]
    with pytest.raises(StudioError):
        await studio.asset(reupload["id"], "pippa")
    assert not (await studio.export_policy(reupload["id"]))["allowed"]

External links

Exercise

간선이 두 종류인 작은 미디어 저장소를 모델링해 봐. 하나는 무엇으로 만들었는지(레퍼런스), 하나는 바이트가 같은지(SHA-256이 같음)야. 한 항목에서 두 간선 중 어느 쪽으로든 닿는 라벨을 전부 돌려주는 폐포 함수를 짜. 계산은 읽을 때 저장된 행으로 해. 테스트는 세 개 써. 파생된 항목은 원본의 라벨을 물려받는다. 라벨 없이 똑같이 다시 올린 항목도 물려받는다. 다시 인코딩한 사본은 못 물려받는다. 세 번째 테스트는 지우지 마. 네 약속의 경계를 적어 둔 기록이니까.
Hint
Avatars처럼 명시적인 스택과 방문 집합을 써. 그래야 같은 바이트 간선으로 생긴 순환에서 영원히 돌지 않아. 다시 인코딩한 사본은 이미지를 열어서 품질 설정을 바꿔 다시 저장하면 돼. 라벨을 단언하기 전에 다이제스트가 다르다는 것부터 단언해.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인 — 댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.