FSA: Honor umask for saved files

MERGED2026contentfile_system_access
2026. 8. 28.zbnerd 프로필 이미지zbnerd

POSIX 환경의 Chromium에서 File System Access API의
showSaveFilePicker()로 새 로컬 파일을 만들 때 고정된 0600 권한 대신
프로세스의 umask를 반영하도록 개선했습니다. 기존 파일의 권한은 그대로
유지하고, 심볼릭 링크나 FIFO 같은 비정규 파일을 안전하게 거부하도록 파일
생성·초기화 경로도 함께 강화했습니다.

문제 설명

File System Access API 명세
따르면 showSaveFilePicker()는 사용자가 선택한 파일이 존재하지 않으면 빈
파일을 생성하고, 이미 존재하면 내용을 비운 뒤 파일 핸들을 반환해야 합니다.

기존 Chromium 구현은 이 동작을 storage backend의
CreateAndTruncateFile()을 통해 처리했습니다. 이 경로에서 POSIX 로컬 파일을
새로 만들면 권한이 0600으로 고정되어 프로세스의 umask가 반영되지
않았습니다.

예를 들어 사용자의 umask가 0002라면 일반적인 저장 경로에서는 다음과 같은
권한을 기대할 수 있습니다.

기본 생성 모드: 0666
umask:          0002
최종 권한:      0664

하지만 기존 showSaveFilePicker() 경로에서는 항상 0600으로 생성되었습니다.
소유자만 파일을 읽고 쓸 수 있기 때문에 보안상 지나치게 느슨한 문제는
아니었지만, 사용자가 설정한 그룹 공유 정책과 다른 브라우저 저장 경로의
동작을 따르지 않는 일관성 문제가 있었습니다.

단순히 파일 생성 모드를 변경하는 것만으로는 충분하지 않았습니다.

  • 새 파일은 0666을 기본 모드로 사용하면서 프로세스 umask를 적용해야 합니다.
  • 기존 파일을 비울 때는 기존 접근 권한을 변경하지 않아야 합니다.
  • 사용자가 선택한 경로가 심볼릭 링크라면 링크 대상 파일이 의도치 않게
    초기화될 수 있으므로 거부해야 합니다.
  • FIFO를 쓰기 전용으로 열면 reader가 없는 경우 작업이 무기한 대기할 수
    있습니다.
  • ftruncate()를 정규 파일이 아닌 객체에 호출했을 때의 동작은 플랫폼마다
    다를 수 있습니다.
  • Android content URI와 외부 파일 시스템은 로컬 POSIX 경로가 아니므로 기존
    storage backend를 계속 사용해야 합니다.
  • 파일 시스템 작업으로 UI 및 브라우저 메인 시퀀스가 블로킹되어서는 안
    됩니다.

해결 내용

1. POSIX 로컬 파일 전용 생성 함수 추가

content/browser/file_system_access/file_system_access_manager_impl.cc
POSIX이면서 Android가 아닌 플랫폼을 위한
CreateAndTruncateLocalFile()을 추가했습니다.

새 파일을 열 때 기본 모드로 0666을 전달해 최종 권한을 프로세스의 umask가
결정하도록 했습니다.

핵심 흐름을 단순화하면 다음과 같습니다.

base::ScopedFD fd(HANDLE_EINTR(open(
    path.value().c_str(),
    O_CREAT | O_WRONLY | O_NONBLOCK | O_CLOEXEC | O_NOFOLLOW,
    0666)));

if (!fd.is_valid()) {
  return false;
}

struct stat file_info;
if (HANDLE_EINTR(fstat(fd.get(), &file_info)) != 0 ||
    !S_ISREG(file_info.st_mode)) {
  return false;
}

return base::File(std::move(fd)).SetLength(0);

각 플래그는 다음 목적을 가집니다.

  • O_CREAT: 대상이 존재하지 않으면 새 파일을 생성합니다.
  • O_WRONLY: 파일을 초기화할 수 있도록 쓰기 전용으로 엽니다.
  • O_NONBLOCK: reader가 없는 FIFO 같은 특수 파일을 열다가 작업이 멈추는
    것을 방지합니다.
  • O_CLOEXEC: 생성한 파일 디스크립터가 자식 프로세스로 상속되지 않도록
    합니다.
  • O_NOFOLLOW: 선택된 최종 경로가 심볼릭 링크라면 링크를 따라가지 않고
    실패시킵니다.
  • 0666: 실행 비트를 제외한 읽기·쓰기 권한을 기본값으로 제공하고 실제
    권한은 umask가 제한하도록 합니다.

open() 단계에서는 의도적으로 O_TRUNC를 사용하지 않았습니다. 파일 타입을
확인하기 전에 내용을 지우지 않기 위해서입니다.

파일을 연 후에는 경로를 다시 검사하는 대신 동일한 파일 디스크립터에
fstat()을 호출합니다. 이를 통해 열린 객체가 정규 파일인지 확인하고,
S_ISREG()를 통과한 경우에만 SetLength(0)으로 내용을 비웁니다. 심볼릭
링크나 FIFO를 비롯한 비정규 파일은 파일 내용이 변경되기 전에 거부됩니다.

또한 HANDLE_EINTR로 인터럽트된 시스템 호출을 안전하게 재시도하고,
base::ScopedFD를 사용해 모든 반환 경로에서 디스크립터가 자동으로
정리되도록 했습니다.

2. 블로킹 파일 작업을 sequenced file task runner로 분리

POSIX open(), fstat(), truncate 작업은 파일 시스템 상태에 따라 블로킹될
수 있습니다. 따라서 해당 작업을 현재 시퀀스에서 직접 실행하지 않고 다음
경로로 전달했습니다.

context()->default_file_task_runner()->PostTaskAndReplyWithResult(
    FROM_HERE,
    base::BindOnce(&CreateAndTruncateLocalFile, fs_url.path()),
    std::move(did_create_and_truncate));

파일 작업은 기존 sequenced file task runner에서 실행되며, 완료 결과만 원래
시퀀스로 전달됩니다. helper 내부에도 base::ScopedBlockingCall
BlockingType::MAY_BLOCK을 선언해 블로킹 가능성을 명시했습니다.

비동기 작업이 진행되는 동안 FileSystemAccessManagerImpl이 파괴될 가능성에
대비해 완료 콜백은 weak_factory_.GetWeakPtr()를 사용하도록 구성했습니다.

3. 적용 범위를 로컬 POSIX 파일로 제한

새 경로는 다음 조건을 모두 만족할 때만 사용합니다.

BUILDFLAG(IS_POSIX) &&
!BUILDFLAG(IS_ANDROID) &&
entries.front().type == PathType::kLocal

따라서 다음 경로의 기존 동작은 변경하지 않았습니다.

  • Android content URI
  • 외부 또는 가상 파일 시스템
  • POSIX 로컬 파일이 아닌 storage backend 경로
  • Windows 등 비 POSIX 플랫폼

이렇게 플랫폼과 저장소 유형별 책임을 분리해, 로컬 파일 권한 문제를
수정하면서 다른 backend의 동작이 함께 바뀌는 것을 방지했습니다.

4. 주요 코드 변경 규모

최종 Chromium 커밋
두 파일을 수정했습니다.

  • file_system_access_manager_impl.cc: 48줄 추가, 4줄 삭제
  • file_system_chooser_browsertest.cc: 177줄 추가, 6줄 삭제
  • 합계: 225줄 추가, 10줄 삭제

최종 커밋 위치는 refs/heads/main@{#1687775}입니다.

테스트 방법

실제 umask, 심볼릭 링크, FIFO와 같은 운영체제 동작을 확인해야 하므로 새
검증은 단위 테스트보다 IN_PROC_BROWSER_TEST 기반 브라우저 통합 테스트에
집중했습니다.

1. 새 파일이 umask를 반영하는지 검증

기존 SaveFile 테스트에서 RAII 방식의 ScopedUmaskSetter를 사용해 테스트
중 umask를 0002로 설정했습니다.

open() 생성 모드: 0666
umask:            0002
기대 권한:         0664

showSaveFilePicker()가 완료된 직후 GetPosixFilePermissions()로 실제 권한이
0664인지 확인했습니다. ScopedUmaskSetter의 소멸자는 기존 umask를
복원하므로 이후 테스트에 영향을 남기지 않습니다.

2. 기존 파일 권한 보존 및 내용 초기화 검증

SaveFile_TruncatesExistingFile 테스트에서는 기존 파일의 권한을 0620으로
설정한 후 저장 대상으로 선택했습니다.

다음 두 조건을 함께 확인했습니다.

  • 기존 파일의 내용이 정상적으로 비워집니다.
  • 기존 권한 0620이 변경되지 않습니다.

이를 통해 새 파일에만 0666 & ~umask가 적용되고, 기존 파일에는 chmod
같은 권한 변경이 발생하지 않음을 검증했습니다.

3. 심볼릭 링크 거부 검증

SaveFile_RejectsSymbolicLink 테스트를 추가했습니다.

  • 실제 대상 파일과 그 파일을 가리키는 심볼릭 링크를 생성합니다.
  • 심볼릭 링크를 저장 대상으로 반환합니다.
  • showSaveFilePicker() Promise가 거부되는지 확인합니다.
  • 실제 대상 파일의 내용이 변경되지 않았는지 다시 읽어 검증합니다.

이 테스트는 O_NOFOLLOW가 링크 대상을 보호하고 있음을 확인합니다.

4. FIFO 안전성 검증

FIFO에 대해서는 서로 다른 두 상황을 테스트했습니다.

  • SaveFile_RejectsFifo: reader를 미리 열어 write-only open()이 성공하게
    만든 후, fstat()S_ISREG() 검사에서 FIFO가 거부되는지 확인합니다.
  • SaveFile_RejectsFifoWithoutReader: reader가 없는 FIFO를 선택해
    O_NONBLOCK 덕분에 작업이 대기하지 않고 즉시 실패하는지 확인합니다.

첫 번째 테스트는 정규 파일 타입 검사를, 두 번째 테스트는 비동기 작업의
무한 대기 방지를 각각 검증합니다.

5. 외부 파일 시스템 회귀 검증

SaveFile_ExternalPathUsesStorageBackend 테스트에서는 외부 가상 경로를 저장
대상으로 전달했습니다.

테스트 중 umask를 0022로 설정했지만 생성된 파일 권한이 기존 backend
동작인 0600인지 확인했습니다. 이를 통해 새 POSIX 로컬 경로가 외부 파일
시스템 처리까지 가로채지 않는다는 것을 검증했습니다.

6. 기존 관찰자 테스트 및 CQ 검증

커밋의 테스트 메타데이터에는 다음 필터가 명시되었습니다.

FileSystemChooserBrowserTest.SaveFile_*
FileSystemAccessObserverBrowserTest.WritableReports*

Gerrit Patch Set 9는 LUCI CQ dry run을 통과했고, Rahul Singh과 Ming-Ying
Chung의 Code-Review+1을 받은 뒤 full CQ를 거쳐 2026년 8월 28일 main
병합되었습니다.

별도의 성능 벤치마크는 수행하지 않았습니다. 이번 변경은 저장 대화상자에서
파일을 생성하거나 초기화할 때 한 번 실행되는 경로이며, 파일 작업은 기존
sequenced file task runner에서 처리됩니다. 다만 저장 지연에 대한 정량적인
성능 수치는 이 CL의 검증 범위에 포함되지 않았습니다.

배운 점

  • POSIX 파일 생성 권한은 open()에 전달한 mode만으로 결정되지 않고
    mode & ~umask로 결정됩니다.
  • 테스트 값의 선택도 중요합니다. umask 00220644만 검사하면 구현이
    잘못해서 처음부터 0644를 전달해도 통과할 수 있습니다. 리뷰 과정에서
    umask 00020664로 변경해 기본 생성 모드가 실제로 0666인지까지
    검증했습니다.
  • umask는 프로세스 전역 상태이므로 테스트에서는 적용 구간을 최소화하고
    RAII로 반드시 원래 값으로 복원해야 합니다.
  • 경로를 먼저 검사하고 나중에 다시 여는 방식은 검사와 사용 사이에 파일이
    바뀔 수 있습니다. 열린 디스크립터에 fstat()을 적용하면 실제로 사용할
    객체 자체를 검증할 수 있습니다.
  • O_TRUNCopen()과 함께 사용하면 파일 타입을 확인하기 전에 데이터가
    변경될 수 있습니다. 먼저 안전하게 연 뒤 정규 파일임을 확인하고
    truncate하는 순서가 중요합니다.
  • O_NONBLOCK은 FIFO처럼 open() 자체가 대기할 수 있는 파일에 대한 방어
    수단이며, S_ISREG() 검사는 open()에 성공한 특수 파일까지 거부하는
    별도의 방어 계층입니다.
  • 로컬 파일, Android content URI, 외부 파일 시스템은 같은 저장 API를
    사용하더라도 서로 다른 backend 의미를 가질 수 있으므로 수정 범위를
    명확히 제한해야 합니다.
  • 리뷰 의견을 통해 테스트 assertion을 showSaveFilePicker() 직후로
    옮겼습니다. 검증하려는 동작과 assertion을 가까이 배치하면 이후의
    createWritable() 동작과 혼동하지 않고 실패 원인을 파악할 수 있습니다.
  • 향후 유사한 POSIX 로컬 파일 생성 경로가 추가된다면 이번 helper의 공통화
    여부를 검토할 수 있습니다. 또한 다른 특수 파일 타입과 플랫폼별 오류
    변환에 대한 회귀 테스트도 확장할 수 있습니다.

참고 자료