← 홈Setup Tip

Setup Tip

Setup Tip — 경로 포함 검사는 양쪽을 같은 방식으로 정규화해야 한다

CLI의 설치 단계가 '이 경로는 관리 범위 안인가'를 검사하면서 범위 쪽만 realpath로 풀고, 목적지는 받은 그대로 비교했다. macOS에서 `/var`는 `/private/var`로 가는 심볼릭 링크라서, 홈 디렉터리 바로 아래 경로가 '범위 밖'으로 판정됐다. macOS CI에서 테스트 113개가 같은 메시지로 실패했지만, 같은 작업이 예전 릴리스에서 시간 초과로 자주 떨어졌던 탓에 하루 종일 플레이크로 취급됐다. 비교하는 두 경로는 같은 방식으로 정규화하고, 아직 없는 경로는 존재하는 조상까지만 풀고, 심볼릭 링크 홈을 쓰는 테스트를 리눅스에서도 돌린다.

무슨 일이 있었나

우리가 관리하는 CLI 도구 하나는 설치 단계에서 설정 파일을 사용자 홈 아래에 쓴다. 엉뚱한 곳에 쓰지 않도록, 쓰기 전에 '목적지가 관리 범위(홈 디렉터리) 안에 있는가'를 검사한다. 새 릴리스가 나간 뒤 macOS CI의 보안 사전 점검 작업이 실패하기 시작했다. 실패한 설치 테스트 113개는 모두 같은 문장을 남겼다: Destination /var/folders/…/home/.<tool> is outside managed scope /private/var/folders/…/home.

무엇이 잘못됐나

두 경로를 나란히 놓으면 바로 보인다. 범위는 /private/var/...로 시작하고 목적지는 /var/...로 시작한다. macOS에서 /var, /tmp, /etc는 /private 아래 실제 디렉터리로 가는 심볼릭 링크다. 코드는 범위 쪽 경로에만 realpath를 적용해 링크를 풀었고, 목적지는 받은 문자열 그대로 비교했다. 같은 디렉터리를 가리키는 두 표기를 문자열로 비교했으니, 홈 바로 아래 경로가 홈 밖으로 판정됐다.

왜 하루 종일 플레이크로 보였나

같은 macOS 작업이 이전 릴리스들에서 30분 시간 초과로 취소되는 일이 잦았다. 그래서 이번 빨간불도 '또 그 작업'으로 읽혔고, 재실행과 병합이 이어졌다. 그런데 이번 실패는 시간 초과가 아니라 113개의 단정 실패였다. 작업 로그를 한 번만 열었어도 구분됐을 차이다. 결국 이 문제는 홈이나 설정 디렉터리가 심볼릭 링크를 거치는 사용자에게 그대로 배포됐다.

재현 (리눅스에서도 된다)

macOS가 없어도 재현할 수 있다. real/home 디렉터리를 만들고 link -> real 심볼릭 링크를 건 뒤, 홈을 link/home으로 잡는다. 범위는 realpath(home) = .../real/home, 목적지는 link/home/.tool. Node로 두 값을 비교하면 '범위 밖'이 나온다. 목적지도 같은 방식으로 풀면 .../real/home/.tool이 되어 '범위 안'이 나온다.

고칠 때의 함정 1: 아직 없는 경로

목적지는 대개 아직 만들어지지 않은 파일이다. Node의 fs.realpathSync는 존재하지 않는 경로에 대해 ENOENT를 던진다. 그래서 존재하는 가장 가까운 조상 디렉터리까지 올라가 그것을 realpath로 풀고, 남은 경로 조각을 다시 붙인다. 파이썬의 os.path.realpath는 기본값이 엄격하지 않아서 존재하는 부분까지만 풀고 나머지를 그대로 붙여 준다. 언어마다 동작이 다르니 확인하고 쓴다.

고칠 때의 함정 2: 접두사 비교

정규화를 맞춘 뒤에도 dest.startsWith(scope)로 비교하면 /x/home-evil이 /x/home 안에 있다고 나온다. path.relative(scope, dest)를 구해서 결과가 빈 문자열이거나, ..으로 시작하지 않고 절대 경로도 아니면 안쪽이라고 판정한다.

규칙

비교하는 두 경로는 반드시 같은 함수로 정규화한다. 한쪽만 realpath를 적용하는 것이 가장 나쁜 조합이다. 둘 다 풀지 않으면 적어도 일관성은 있고, 둘 다 풀면 정확하다. 한쪽만 풀면 심볼릭 링크가 하나만 끼어도 틀린다.

테스트

심볼릭 링크를 거치는 홈 디렉터리를 만드는 테스트를 리눅스 CI에서도 돌린다. macOS 러너는 느리고 자주 시간 초과가 나서, 그 결과만 믿으면 이번처럼 진짜 실패가 소음에 묻힌다. 같은 작업이 연속된 릴리스에서 빨간색이면, 재실행하기 전에 로그부터 읽는다.

정리

'범위 밖입니다'라는 오류 메시지에 두 경로가 나란히 찍혀 있었다. 앞부분이 /var와 /private/var로 다르다는 것이 답의 전부였다. 경로 검사는 문자열 비교가 아니라 같은 규칙으로 정규화한 두 경로의 비교여야 한다.