[Blink] Preserve FormData File identity

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

Chromium Blink의 FormDataBlob과 이름이 변경된 File을 조회할 때마다
새로운 File 객체를 만들던 동작을 수정했습니다. 값을 entry에 추가하는
시점에 한 번만 File로 정규화하여 get(), getAll(), iterator가 동일한
객체를 반환하게 하고, multipart 직렬화에 필요한 파일 메타데이터도
보존했습니다.

문제 설명

HTML Standard의 create-an-entry 알고리즘
FormData entry를 생성할 때 BlobFile 값을 다음과 같이 처리합니다.

  1. 값이 File이 아닌 Blob이면 같은 바이트를 나타내고 이름이 "blob"
    File을 만듭니다.
  2. filename이 주어졌다면 같은 바이트를 나타내고 해당 이름을 가진 새
    File을 만듭니다.
  3. 정규화된 값을 entry의 value로 저장합니다.

XMLHttpRequest Standard의 FormData 알고리즘
append()set()이 이 create-an-entry 알고리즘으로 entry를 만든다고
규정합니다. 이후 get(), getAll(), iterator는 entry list에 이미 저장된
value를 반환합니다.

그러나 기존 Blink 구현은 생성 시점에 값을 정규화하지 않았습니다.
FormData::EntryMember<Blob> blob_과 별도의 filename_을 저장하고,
GetFile()이 호출될 때마다 다음 변환을 수행했습니다.

  • 일반 Blob이면 이름이 "blob" 또는 지정된 filename인 File을 새로
    생성했습니다.
  • 기존 File에 filename override가 있으면 File::Clone()으로 매번 새
    객체를 만들었습니다.
  • filename override가 없는 기존 File만 원래 객체를 그대로 반환했습니다.

따라서 다음과 같이 같은 entry를 반복해서 조회해도 서로 다른 객체가
반환됐습니다.

const data = new FormData();
data.set("blob", new Blob());

data.get("blob") === data.get("blob"); // 기존 Blink에서는 false
data.get("blob") === data.getAll("blob")[0]; // false
data.get("blob") === Array.from(data)[0][1]; // false

일반 BlobFile로 바꿀 때 base::Time::Now()를 사용했기 때문에 새
객체뿐 아니라 합성된 lastModified 값도 조회 시점마다 달라질 수 있었습니다.
이는 같은 entry의 저장된 value를 반환해야 하는 명세의 데이터 모델과 맞지
않았으며, Window와 Worker에서 실행되는 WPT가 모두 실패하는 원인이었습니다.

수정할 때는 객체 identity만 맞추는 것으로 충분하지 않았습니다.

  • filename을 생략한 경우와 명시적으로 빈 문자열을 전달한 경우를 구분해야
    합니다.
  • filename override가 없는 기존 File은 원래 객체 identity를 유지해야
    합니다.
  • filename override가 있는 기존 File은 한 번만 clone하면서 backing file
    경로, 상대 경로, Blob data handle, 수정 시간을 보존해야 합니다.
  • multipart 직렬화에서 webkitRelativePath, 명시적 filename override,
    MIME type, backing file 경로를 기존과 동일하게 처리해야 합니다.

해결 내용

1. entry 생성 시 한 번만 File로 정규화

third_party/blink/renderer/core/html/forms/form_data.cc의 anonymous
namespace에 CreateFileFromBlob() helper를 추가했습니다.

File* CreateFileFromBlob(Blob* blob, const String& filename) {
  if (!blob) {
    return nullptr;
  }

  if (auto* file = DynamicTo<File>(blob)) {
    if (filename.IsNull()) {
      return file;
    }
    return file->Clone(filename);
  }

  return MakeGarbageCollected<File>(filename.IsNull() ? "blob" : filename,
                                    base::Time::Now(),
                                    blob->GetBlobDataHandle());
}

입력 조합별 동작은 다음과 같습니다.

입력 값 filename entry에 저장되는 값
null 무관 null
File 생략 원래 File 객체
File 지정 지정된 이름으로 한 번 clone한 File
일반 Blob 생략 이름이 "blob"인 새 File
일반 Blob 지정 지정된 이름을 가진 새 File

FormData::Entry의 Blob 생성자는 이 helper를 즉시 호출합니다.

FormData::Entry::Entry(const String& name,
                       Blob* blob,
                       const String& filename)
    : name_(name),
      file_(CreateFileFromBlob(blob, filename)),
      filename_(filename) {}

이제 base::Time::Now()File::Clone()은 entry 생성 중 필요한 경우에만 한
번 실행됩니다. get(), getAll(), iterator가 값을 읽을 때는 추가 객체를
만들지 않습니다.

2. Entry가 Blob 대신 정규화된 File을 저장

third_party/blink/renderer/core/html/forms/form_data.h에서 entry의 저장
필드를 Member<Blob>에서 Member<File>로 변경했습니다.

-const Member<Blob> blob_;
+const Member<File> file_;

이에 맞춰 IsString()isFile()file_의 존재 여부를 확인하고,
Trace()file_을 추적합니다. GetFile()은 저장된 포인터를 그대로
반환합니다.

Blob* FormData::Entry::GetBlob() const {
  return file_.Get();
}

File* FormData::Entry::GetFile() const {
  DCHECK(file_);
  return file_.Get();
}

FileBlob의 하위 타입이므로 기존 Blob 기반 호출 경로를 유지하기 위해
GetBlob()도 남겼습니다. 다만 실제 entry 내부에는 이미 정규화된 File
저장됩니다.

3. filename의 null과 빈 문자열을 구분

리뷰에서는 filename.IsNull()이 빈 문자열과 다른 동작을 만드는 점이
논의됐습니다. 이 차이는 의도된 동작입니다.

  • null String: JavaScript 호출에서 filename 인수가 생략됐음을 나타냅니다.
  • String: 개발자가 filename으로 ""을 명시했음을 나타냅니다.

filename이 생략된 기존 File은 원래 객체와 이름을 유지합니다. 반면 빈
filename이 명시되면 빈 이름을 가진 File을 한 번 clone합니다. 단순히
filename.empty()로 검사하면 이 두 API 입력을 구분할 수 없으므로 기존의
IsNull() 의미를 유지했습니다.

4. backing file 메타데이터와 multipart 동작 보존

third_party/blink/renderer/core/html/forms/form_data.cc
EncodeMultiPartFormData()도 정규화된 File을 직접 사용하도록
정리했습니다.

File* file = entry->isFile() ? entry->GetFile() : nullptr;

multipart header와 body를 만드는 기존 의미는 유지했습니다.

  • webkitRelativePath()가 있으면 상대 경로를 filename으로 사용합니다.
  • append() 또는 set()에 filename을 명시했다면 그 값이 최종적으로
    우선합니다.
  • 파일의 MIME type이 비어 있으면 application/octet-stream을 사용합니다.
  • backing file의 경로가 있으면 경로와 LastModifiedTime()을 encoded file
    element에 전달합니다.
  • 메모리 기반 파일이면 동일한 Blob data handle을 append합니다.

File::Clone(filename)이 단순히 바이트만 복사하지 않는지도 단위 테스트로
확인했습니다. 이름이 바뀐 clone은 원래 파일과 다른 객체이지만 다음 정보는
그대로 유지됩니다.

  • backing file 경로
  • webkitRelativePath
  • Blob data handle
  • LastModifiedTime()

entry에는 filename_도 계속 보관합니다. 이를 통해 정규화 시점이 바뀌어도
multipart 직렬화에서 명시적 filename override가 상대 경로보다 우선하는
기존 동작을 유지합니다.

5. 변경 파일과 규모

최종 Chromium 커밋
다음 6개 경로를 변경했습니다.

  1. third_party/blink/renderer/core/html/forms/form_data.cc

    • 47줄 추가, 46줄 삭제
    • CreateFileFromBlob() 추가
    • entry 생성 시점 정규화와 multipart 직렬화 경로 변경
  2. third_party/blink/renderer/core/html/forms/form_data.h

    • 4줄 추가, 4줄 삭제
    • Member<Blob>Member<File>로 변경
    • GetBlob() 구현을 소스 파일로 이동하고 export 유지
  3. third_party/blink/renderer/core/html/forms/form_data_test.cc

    • 47줄 추가
    • Blob 변환과 named File clone의 identity 및 메타데이터 단위 테스트 추가
  4. third_party/blink/web_tests/external/wpt/xhr/formdata/set-blob.any.js

    • 25줄 추가, 1줄 삭제
    • get(), getAll(), iterator의 File identity 검증 확대
    • set()append(), 기본 이름과 custom filename 조합 검증
  5. third_party/blink/web_tests/external/wpt/xhr/formdata/set-blob.any-expected.txt

    • 5줄 삭제 후 파일 제거
    • Window 환경의 기존 실패 expectation 제거
  6. third_party/blink/web_tests/external/wpt/xhr/formdata/set-blob.any.worker-expected.txt

    • 5줄 삭제 후 파일 제거
    • Worker 환경의 기존 실패 expectation 제거

전체 변경 규모는 123줄 추가, 61줄 삭제입니다. 최종 커밋 위치는
refs/heads/main@{#1688362}입니다.

테스트 방법

1. Blob entry 단위 테스트

BlobEntryCreatesFileOnce 테스트를
third_party/blink/renderer/core/html/forms/form_data_test.cc
추가했습니다.

이 테스트는 일반 Blob을 append한 뒤 다음을 확인합니다.

  • 저장된 값이 기본 이름 "blob"을 가진 File인지
  • 원래 Blob과 동일한 Blob data handle을 사용하는지
  • Entries()[0]->GetFile()을 반복해도 같은 포인터인지
  • get()getAll()도 같은 File 포인터를 반환하는지

2. filename override와 backing file 단위 테스트

같은 파일에 추가한 FileEntryWithFilenameIsClonedOnce 테스트는 backing
file을 나타내는 File"renamed.txt" filename override를 적용합니다.

다음 조건을 함께 검증합니다.

  • 원래 File과 clone은 서로 다른 객체입니다.
  • clone의 이름은 "renamed.txt"입니다.
  • backing file 경로 /tmp/form_data_test가 유지됩니다.
  • webkitRelativePathrelative/original.txt가 유지됩니다.
  • Blob data handle과 수정 시간이 보존됩니다.
  • multipart header의 filename은 "renamed.txt"입니다.
  • encoded body는 Blob이 아니라 backing file element를 사용합니다.
  • entry를 다시 조회해도 같은 clone을 반환합니다.

3. WPT File identity 검증 확대

third_party/blink/web_tests/external/wpt/xhr/formdata/set-blob.any.js
assert_file_identity() helper를 추가했습니다.

function assert_file_identity(formData, name, expected) {
  assert_equals(formData.get(name), expected);
  assert_equals(formData.getAll(name)[0], expected);
  const entry = Array.from(formData)
      .find(([entryName]) => entryName === name);
  assert_equals(entry[1], expected);
}

이 helper를 사용해 다음 경우를 검증합니다.

  • set()에 일반 Blob과 기본 filename을 사용한 경우
  • set()에 일반 Blob과 custom filename을 사용한 경우
  • set()에 File을 filename override 없이 사용한 경우
  • set()에 File과 custom filename을 사용한 경우
  • append()에 일반 Blob을 사용한 경우
  • append()에 일반 Blob과 custom filename을 사용한 경우

기존 WPT는 Blob 기본 이름, type, lastModified도 계속 검사합니다. 변경 후
Window와 Worker의 기존 failure expectation이 필요 없어져 두 expected 파일을
삭제했습니다.

커밋 메시지에는 xhr/formdata WPT 27개를 실행했다고 기록했습니다. Blink
WPT 변경은 Chromium의 자동 export 과정을 통해
web-platform-tests/wpt PR #62284로도
전달됐습니다.

구현과 테스트가 포함된 다음 대상을 빌드했습니다.

  • form_data.o
  • form_data_test.o
  • libblink_core.so

별도의 성능 벤치마크는 수행하지 않았습니다. 이번 변경은 조회할 때마다
객체를 생성하던 작업을 entry 생성 시 한 번으로 옮기므로 반복 조회 시 객체
할당은 줄어들지만, 정량적인 성능 수치는 이 CL의 검증 범위에 포함되지
않았습니다.

5. 리뷰와 CQ 검증

Gerrit Patch Set 4는 2026년 8월 24일 LUCI CQ dry run을 통과했습니다. 리뷰
과정에서는 다음 사항을 확인했습니다.

  • Keishi Hattori가 전체 변경에 LGTM을 남겼습니다.
  • Joey Arhar가 Code-Review+1을 부여하고 filename의 null과 빈 문자열 차이를
    질문했습니다.
  • IsNull()이 filename 인수 생략과 명시적 빈 filename을 구분하기 위한
    의도된 동작임을 설명했습니다.
  • Mason Freed가 Code-Review+1Commit-Queue+2를 부여했습니다.

full CQ가 Patch Set 4를 검증한 뒤 최종 제출 과정에서 Patch Set 5로 rebase했고,
2026년 8월 28일 main에 병합됐습니다.

배운 점

  • 표준 알고리즘은 최종 값의 타입뿐 아니라 변환이 일어나는 시점까지
    규정할 수 있습니다. create-an-entry에서 값을 한 번 정규화해야 이후 조회
    API가 같은 entry value를 반환할 수 있습니다.
  • 같은 바이트와 메타데이터를 가진 두 File도 JavaScript에서는 서로 다른
    객체입니다. 웹 API의 객체 identity는 ===로 관찰할 수 있는 호환성
    요구사항입니다.
  • 조회 함수 안에서 base::Time::Now()를 사용해 객체를 합성하면 호출할
    때마다 lastModified 같은 관찰 가능한 값이 달라질 수 있습니다.
  • optional string에서는 null과 빈 문자열이 서로 다른 API 입력을 나타낼 수
    있습니다. IsNull()empty()로 단순화하면 filename 생략과 빈 filename
    지정을 구분하지 못합니다.
  • 객체 생성 시점을 옮길 때는 조회 경로만 보지 않고 multipart 직렬화처럼
    같은 내부 표현을 소비하는 경로도 함께 확인해야 합니다.
  • 이름을 바꾸기 위해 File을 clone하더라도 backing file 경로, 상대 경로,
    Blob handle, 수정 시간처럼 직렬화에 필요한 메타데이터를 보존해야 합니다.
  • 구현 단위 테스트는 내부 포인터와 backing metadata를 검증하고, WPT는
    JavaScript에서 관찰되는 identity를 검증합니다. 두 계층을 함께 사용하면
    내부 표현과 웹 표준 동작을 모두 보호할 수 있습니다.
  • WPT가 통과한 뒤 기존 expected failure 파일을 제거해야 실제 회귀가 다시
    발생했을 때 테스트 실패를 숨기지 않습니다.
  • Chromium의 external/wpt 변경은 Blink W3C Test Autoroller가 upstream WPT
    PR로 export하므로 Chromium 내부 수정과 웹 플랫폼 공통 테스트의 흐름을
    함께 확인할 수 있습니다.

참고 자료