Lobsters

A fast and secure tar parser for the Rust ecosystem

Rust 생태계를 위한 빠르고 안전한 tar 파서

uv 0.13은 기존 구현보다 약 2배 빠르면서 파서 간 해석 차이를 줄이는 tar-codec을 소스 배포판 처리에 기본 적용합니다. 글은 tar 형식별 상태와 확장 규칙, 안전한 추출 구조를 설명하고 호환성과 엄격한 검증 사이의 설계 선택을 다룹니다.

에디터 노트

파서 간 해석 차이를 줄이려면 허용 형식을 좁히고 상태 전이를 분리해야 한다는 설계가 글의 근거와 맞습니다. tar-codec은 첫 헤더로 형식 계열을 고정하고 혼합 헤더를 거부하며, pax의 우선순위와 삭제 상태도 명시적으로 처리합니다. 다만 PyPI에 있는 아카이브 전체를 대상으로 한 호환성 측정 결과는 제시하지 않습니다. 댓글에서 언급한 BigQuery 집계는 한 참여자가 제시한 자료로, 실제 PyPI 아카이브가 수백만 개 규모라는 점을 보여주는 참고 정보입니다. 그 규모는 형식 지원 범위를 데이터로 점검하자는 제안에 힘을 싣지만, tar-codec이 해당 전체를 통과하는지는 확인해 주지 않습니다. 또 파서 해석 차이를 막는 일과 추출 경로를 안전하게 제한하는 일은 별도 층위입니다. 글은 cap-std 기반 추출과 macOS에서의 성능 비용을 함께 밝히며 두 문제를 구분합니다.

AI 요약

uv는 0.13부터 Python 소스 배포판(sdist)을 읽을 때 tar-codec을 기본 사용합니다. Astral은 기존 astral-tokio-tar에서 보안 문제와 파서 간 해석 차이를 확인했고, 기존 코드에 제약을 덧붙이는 대신 새 파서를 만들었습니다. tar-codec은 기존 구현보다 약 2배 빠르며, 다른 파서가 같은 아카이브를 다르게 해석해 서로 다른 파일을 추출하는 위험을 줄이는 것을 목표로 합니다.

tar 형식의 복잡성

tar는 512바이트 블록을 이어 붙이는 형식입니다. 각 헤더 블록이 다음 파일의 크기와 경로를 지정하고, 파일 내용은 블록 단위로 저장합니다. UNIX V7 형식은 경로 길이 100바이트, 파일 크기 약 8GiB라는 제한이 있습니다. UStar는 경로 길이를 최대 255바이트로 늘리고 디렉터리 등 여러 항목 유형을 정의합니다. pax는 별도 확장 헤더에 길이·경로 같은 값을 기록해 크기와 경로 길이 제한을 없앱니다. GNU tar는 숫자 필드의 base-256 인코딩과 L·K 형식의 확장 항목으로 긴 경로와 링크 이름 등을 처리합니다.

여러 형식의 확장 규칙이 섞이면 같은 바이트열을 각 파서가 다르게 읽을 수 있습니다. pax의 확장 레코드는 길이 접두어를 포함하므로 줄바꿈 문자로만 나눠 읽으면 레코드 경계가 어긋날 수 있습니다. 전역 확장 헤더와 지역 확장 헤더의 우선순위도 다르고, 빈 값은 앞선 값을 삭제하는 tombstone으로 작동합니다. GNU 확장 헤더와 pax 헤더를 함께 허용하는 경우에는 어느 헤더가 다음 파일에 적용되는지를 두고 구현별 차이가 생길 수 있습니다.

파서 설계와 추출

tar-codec은 첫 헤더의 magic과 version을 기준으로 UStar/pax 계열인지 GNU 계열인지 정한 뒤, 아카이브 안에서 다른 계열의 헤더가 나오면 거부합니다. GNU의 L·K 확장은 한 항목에 각각 하나씩만 허용합니다. pax의 지역·전역 상태와 우선순위를 별도로 추적하고, 빈 경로처럼 일관된 규칙으로 처리하기 어려운 상태도 거부합니다. 다만 pax가 UTF-8을 요구하는 것과 달리 일부 GNU tar와 star가 공급자별 레코드에 원시 바이너리를 기록한 사례가 있어, 해당 값 처리는 호환성을 고려합니다. UStar의 uname과 gname 필드도 규격상 31바이트까지지만 CPython tarfile이 32바이트를 쓰는 사례를 수용합니다.

구현은 tar-framing의 물리 블록 처리와 논리 항목 조립, tar-codec의 메모리·파일 추출로 층을 나눕니다. archive-trait은 여러 아카이브 형식에 공통으로 적용하는 읽기·쓰기 인터페이스를 제공하고, cap-std를 이용해 지정 디렉터리를 기준으로 파일을 추출합니다. 경로와 링크를 이용한 디렉터리 탈출 위험을 줄이는 구조입니다. 다만 openat2(2)나 이에 준하는 시스템 호출이 없는 환경에서는 경로 구성 요소마다 디렉터리를 열어 확인하므로, 추출 성능에 비용이 생기며 macOS에서 특히 영향이 큽니다.

성능과 범위

인코딩과 디코딩에서는 체크섬 계산, pax 레코드 길이 계산, 메모리 할당 재사용, 작업 버퍼링을 최적화했습니다. tar-codec은 uv 0.12에서 미리보기 기능으로 제공됐고, 0.13부터 기본값이 됐습니다. UV_LEGACY_TAR_BACKEND=1을 지정하면 이전 구현을 사용할 수 있습니다. 현재 Python 패키징 용도에 필요한 기능은 갖췄지만 sparse 파일과 공급자별 pax 레코드 지원은 필요에 따라 추가할 계획입니다. Astral은 다른 tar 구현의 테스트를 돕는 tar-minefield 프로젝트와 협력하고, 비슷한 이유로 uv의 ZIP 파서도 교체하는 작업을 검토하고 있습니다.

Lobsters 반응

  • @abrambleninja — 설명과 그림이 매우 좋습니다. 상태 머신 그림이 복잡한 내용을 이해하는 데 도움이 됩니다. 덧붙여, Kindle 업데이트 tar에서 경로 순회로 /etc/authorized_keys를 덮어써 승인되지 않은 앱을 실행한 취약점이 떠오릅니다. 본문에서 다룬 파서 차이와는 다른 종류의 문제지만, 아카이브 보안에 여러 층위가 있음을 보여줍니다.
    • @yossarian — 아카이브 항목을 실제 파일로 만드는 추출 계층은 어떤 면에서 더 어렵습니다. 운영체제와 파일시스템마다 의미와 예외가 달라 아카이브가 서로 다른 환경에서 똑같이 풀린다고 확신하기 어렵습니다. 출력 가능한 ASCII 상대 경로만 쓰고 길이를 보수적으로 제한한 아카이브라면 예외일 수 있지만, 실제 용도에서는 이런 조건을 벗어나는 경우가 많습니다.
  • @andyc — 호환성과 파서 차이 방지는 서로 반대 방향으로 작용하는 듯합니다. PyPI에 실제로 있는 소스 tarball만 살펴보면 필요한 tar 기능 범위를 정할 수 있지 않을까요? PyPI 업로드 단계에서 거부하는 방법도 생각할 수 있지만 사회적·운영적 어려움은 이해합니다. GNU tar, macOS의 BSD 계열 tar, Python tarfile, Go tar가 실제로 쓰는 확장 기능의 합집합이 tar의 모든 확장보다 작을지도 궁금합니다. Docker·OCI 아카이브도 PyPI와 같은 도구로 만들어지는지 질문합니다.
    • @masklinn — PyPI 홈페이지는 프로젝트 100만 개와 릴리스 1,000만 개를 안내합니다. 상위 3,000개 프로젝트 중 95%, 무작위 표본 2,850개 중 91%에 sdist가 있었다는 조사도 있습니다. 과거에는 sdist 비중이 더 컸을 가능성이 있어, 소스 tarball은 800만~900만 개일 것이라고 봅니다.
    • @bakkot — PyPI 패키지 메타데이터가 BigQuery에 있습니다. .tar.gz 파일은 986만 개, 전체 용량은 11.99TB, 평균 크기는 약 1.2MB입니다. 99백분위 크기까지만 받으면 전체 데이터 양은 대략 절반일 것이라고 추정합니다.
    • @andyc — 이 점은 자신이 작업해 온 J8 Notation과도 맞닿습니다. CSV·TSV·HTML5·XML과 폭넓게 호환되면서 JSON처럼 명세가 분명한 형식을 찾고 싶습니다. 대표성 있는 데이터셋에서 호환성과 명세의 경계를 실험할 수 있으며, tar에는 PyPI와 OCI가 좋은 표본이 될 수 있습니다. 명시적 상태 머신을 사용한 tar-codec의 접근이 마음에 듭니다. 이 문제는 메모리 안전성과 무관하므로 C·C++ 등 다른 언어에도 같은 이점이 적용될 수 있습니다.
    • @fanf — Kaitai Struct도 참고할 수 있습니다. (댓글 6개)
  • @hjvt — 오래된 Unix 형식에서 정수 대신 8진수 문자열을 쓴 배경이 궁금합니다.
    • @masklinn — PDP 계열은 18·12·36비트 워드 크기를 사용해 8진 표기가 흔했습니다. PDP-11은 드문 16비트 아키텍처였지만 스위치, 어셈블리 즉시값, 주소와 장치 레지스터에 8진 표기를 널리 썼습니다. 초기 바이너리 덤프 도구 이름도 od(octal dump)였고, Bourne shell의 do...done 구문이 do...od 대신 쓰인 이유라는 설도 있습니다.
    • @fanf — PDP-11 기계어의 레지스터 번호와 주소 지정 모드 필드는 8진 숫자 단위로 정렬돼 있어, 디스어셈블러 없이도 8진 덤프를 읽기 쉬웠습니다. ARM32에서는 같은 방식으로 16진 숫자 단위 정렬을 볼 수 있습니다.

원문: Astral / 번역·요약: Trawling