Skip to content

dart:io on Windows treats every reparse point as a link instead of checking IsReparseTagNameSurrogate #64535

Open

Description

@sigurdm

Problem

On Windows, dart:io classifies any entity with FILE_ATTRIBUTE_REPARSE_POINT (except IO_REPARSE_TAG_AF_UNIX) as a Link (in HandleFindFile, GetTypeAndAttributes, DeleteLinkHelper, and RecursiveDeleter::DeleteDirectory).

Only name-surrogate reparse points (IsReparseTagNameSurrogate(tag), i.e. IO_REPARSE_TAG_SYMLINK and IO_REPARSE_TAG_MOUNT_POINT) redirect to another path. Windows also uses reparse points for ordinary files and directories that carry filter-driver metadata:

  • Cloud Files sync roots and placeholders (IO_REPARSE_TAG_CLOUD*, used by OneDrive Files On-Demand)
  • App execution aliases in %LOCALAPPDATA%\Microsoft\WindowsApps (IO_REPARSE_TAG_APPEXECLINK, e.g. wt.exe, winget.exe, python3.exe)
  • Projected File System directories/files (IO_REPARSE_TAG_PROJFS, e.g. VFSForGit)
  • WOF-compressed (IO_REPARSE_TAG_WOF) and deduplicated (IO_REPARSE_TAG_DEDUP) files

As Microsoft notes in Build a cloud file sync engine: "A common misconception about reparse points is that they are the same as symbolic links."

What breaks

When a non-surrogate reparse point has FILE_ATTRIBUTE_REPARSE_POINT visible to the process:

  1. Directory.list(followLinks: false) yields Link instead of File or Directory, and with recursive: true, followLinks: false does not descend into reparse-point directories (HandleFindFile in runtime/bin/directory_win.cc).
  2. FileSystemEntity.typeSync(path, followLinks: false) returns FileSystemEntityType.link and isLinkSync(path) returns true (GetTypeAndAttributes in runtime/bin/file_win.cc).
  3. FileSystemEntity.typeSync(path, followLinks: true) and File(path).existsSync() return notFound / false for app execution aliases (IO_REPARSE_TAG_APPEXECLINK in %LOCALAPPDATA%\Microsoft\WindowsApps), because GetTypeAndAttributes calls ResolveReparsePoint (CreateFileW without FILE_FLAG_OPEN_REPARSE_POINT), which fails on IO_REPARSE_TAG_APPEXECLINK stubs even though GetFileAttributesExW already succeeded.
  4. Link(path).targetSync() throws FileSystemException: Cannot read link ... (OS Error: The file or directory is not a reparse point, errno = 4390), because File::LinkTarget in runtime/bin/file_win.cc only accepts IO_REPARSE_TAG_SYMLINK and IO_REPARSE_TAG_MOUNT_POINT.
  5. Directory.delete(recursive: true) on a tree containing a non-empty reparse-point directory fails with OS Error: The directory is not empty, errno = 145, because RecursiveDeleter::DeleteDirectory in runtime/bin/directory_win.cc assumes any reparse point is a junction (Recursive directory delete traverses into junctions (symlinks) on Windows #4560) and calls RemoveDirectoryW without deleting children.

Note on OneDrive / Cloud Files (cldflt.sys)

By default, cldflt.sys runs non-%systemroot% processes such as dart.exe in PHCM_DISGUISE_PLACEHOLDER (1) mode, which hides FILE_ATTRIBUTE_REPARSE_POINT on files and subfolders inside a sync root. Even in default mode, however, cldflt.sys does not hide the attribute on:

When PHCM_EXPOSE_PLACEHOLDERS (2) is enabled via RtlSetProcessPlaceholderCompatibilityMode — and unconditionally for non-Cloud-Filter tags (IO_REPARSE_TAG_APPEXECLINK, IO_REPARSE_TAG_PROJFS, IO_REPARSE_TAG_DEDUP, IO_REPARSE_TAG_WOF), which have no disguise shim — all symptoms above occur on the files and directories themselves.

Reproduction

1. App execution aliases (no FFI, any Windows 10/11 or Server 2022/2025 machine)

import 'dart:io';

void main() {
  final apps = Directory(
    '${Platform.environment['LOCALAPPDATA']}\\Microsoft\\WindowsApps',
  );
  final alias = apps
      .listSync(followLinks: false)
      .firstWhere((e) => e.path.endsWith('.exe'));
  print('listed as: ${alias.runtimeType} (${alias.path})');
  print('typeSync(followLinks: false): '
      '${FileSystemEntity.typeSync(alias.path, followLinks: false)}');
  print('typeSync(followLinks: true):  '
      '${FileSystemEntity.typeSync(alias.path, followLinks: true)}');
  print('File.existsSync(): ${File(alias.path).existsSync()}');
  print('Link.targetSync(): ${Link(alias.path).targetSync()}');
}

Output on stock Dart 3.13.5 (stable):

listed as: _Link (C:\Users\runneradmin\AppData\Local\Microsoft\WindowsApps\ActionsMcpHost.exe)
typeSync(followLinks: false): link
typeSync(followLinks: true):  notFound
File.existsSync(): false
Unhandled exception:
FileSystemException: Cannot read link, path = 'C:\Users\runneradmin\AppData\Local\Microsoft\WindowsApps\ActionsMcpHost.exe' (OS Error: The file or directory is not a reparse point, errno = 4390)

Output with the proposed IsReparseTagNameSurrogate fix:

listed as: _File (C:\Users\chrome-bot\AppData\Local\Microsoft\WindowsApps\ActionsMcpHost.exe)
typeSync(followLinks: false): file
typeSync(followLinks: true):  file
File.existsSync(): true
Unhandled exception:
FileSystemException: Cannot read link, path = 'C:\Users\chrome-bot\AppData\Local\Microsoft\WindowsApps\ActionsMcpHost.exe' (OS Error: The file or directory is not a reparse point, errno = 4390)

(The same applies to winget.exe, wt.exe, and python3.exe in WindowsApps.)

2. Cloud Files sync root and placeholders (directories + files)

Registers a temporary Cloud Filter sync root and converts sub, sub\sub_sub, and plain.txt into placeholders (ffi: ^2.1.0):

issue_repro.dart
import 'dart:ffi';
import 'dart:io';

import 'package:ffi/ffi.dart';

final class GUID extends Struct {
  @Uint32()
  external int data1;
  @Uint16()
  external int data2;
  @Uint16()
  external int data3;
  @Array(8)
  external Array<Uint8> data4;
}

final class CF_SYNC_REGISTRATION extends Struct {
  @Uint32()
  external int structSize;
  external Pointer<Utf16> providerName;
  external Pointer<Utf16> providerVersion;
  external Pointer<Void> syncRootIdentity;
  @Uint32()
  external int syncRootIdentityLength;
  external Pointer<Void> fileIdentity;
  @Uint32()
  external int fileIdentityLength;
  external GUID providerId;
}

final class CF_SYNC_POLICIES extends Struct {
  @Uint32()
  external int structSize;
  @Uint16()
  external int hydrationPrimary;
  @Uint16()
  external int hydrationModifier;
  @Uint16()
  external int populationPrimary;
  @Uint16()
  external int populationModifier;
  @Uint32()
  external int inSync;
  @Uint32()
  external int hardLink;
  @Uint32()
  external int placeholderManagement;
}

final cldapi = DynamicLibrary.open('cldapi.dll');
final kernel32 = DynamicLibrary.open('kernel32.dll');
final ntdll = DynamicLibrary.open('ntdll.dll');

final cfRegisterSyncRoot = cldapi.lookupFunction<
    Int32 Function(Pointer<Utf16>, Pointer<CF_SYNC_REGISTRATION>,
        Pointer<CF_SYNC_POLICIES>, Int32),
    int Function(Pointer<Utf16>, Pointer<CF_SYNC_REGISTRATION>,
        Pointer<CF_SYNC_POLICIES>, int)>('CfRegisterSyncRoot');
final cfConvertToPlaceholder = cldapi.lookupFunction<
    Int32 Function(Pointer<Void>, Pointer<Void>, Uint32, Int32, Pointer<Int64>,
        Pointer<Void>),
    int Function(Pointer<Void>, Pointer<Void>, int, int, Pointer<Int64>,
        Pointer<Void>)>('CfConvertToPlaceholder');
final cfUnregisterSyncRoot = cldapi.lookupFunction<
    Int32 Function(Pointer<Utf16>),
    int Function(Pointer<Utf16>)>('CfUnregisterSyncRoot');
final createFileW = kernel32.lookupFunction<
    Pointer<Void> Function(Pointer<Utf16>, Uint32, Uint32, Pointer<Void>,
        Uint32, Uint32, Pointer<Void>),
    Pointer<Void> Function(Pointer<Utf16>, int, int, Pointer<Void>, int, int,
        Pointer<Void>)>('CreateFileW');
final closeHandle = kernel32.lookupFunction<Int32 Function(Pointer<Void>),
    int Function(Pointer<Void>)>('CloseHandle');
final getFileAttributesW = kernel32.lookupFunction<
    Uint32 Function(Pointer<Utf16>),
    int Function(Pointer<Utf16>)>('GetFileAttributesW');
final queryPlaceholderCompatibilityMode =
    ntdll.lookupFunction<Int8 Function(), int Function()>(
        'RtlQueryProcessPlaceholderCompatibilityMode');
final setPlaceholderCompatibilityMode =
    ntdll.lookupFunction<Int8 Function(Int8), int Function(int)>(
        'RtlSetProcessPlaceholderCompatibilityMode');

void check(int hresult, String call) {
  if (hresult != 0) {
    throw StateError(
        '$call failed: 0x${hresult.toUnsigned(32).toRadixString(16)}');
  }
}

void convertToPlaceholder(Pointer<Utf16> path) {
  final handle = createFileW(path, 0x80000000 | 0x40000000, 0x1 | 0x2 | 0x4,
      nullptr, 3, 0x02000000, nullptr);
  if (handle.address == -1) throw StateError('CreateFileW failed');
  try {
    check(cfConvertToPlaceholder(handle, nullptr, 0, 0x1, nullptr, nullptr),
        'CfConvertToPlaceholder(${path.toDartString()})');
  } finally {
    closeHandle(handle);
  }
}

bool hasReparseAttribute(Pointer<Utf16> path) =>
    getFileAttributesW(path) & 0x400 != 0;

String describe(String path) =>
    'typeSync(followLinks: false)=${FileSystemEntity.typeSync(path, followLinks: false)} '
    'typeSync(followLinks: true)=${FileSystemEntity.typeSync(path, followLinks: true)} '
    'isLinkSync=${FileSystemEntity.isLinkSync(path)}';

List<String> names(Directory directory,
        {required bool recursive, required bool followLinks}) =>
    directory
        .listSync(recursive: recursive, followLinks: followLinks)
        .map((e) =>
            '${e.runtimeType}(${e.path.substring(directory.path.length + 1)})')
        .toList()
      ..sort();

void main() {
  final temp = Directory.systemTemp.createTempSync('placeholders_');
  final syncRoot = Directory('${temp.path}\\sync_root')..createSync();
  final sub = Directory('${syncRoot.path}\\sub')..createSync();
  final subSub = Directory('${sub.path}\\sub_sub')..createSync();
  final nested = File('${subSub.path}\\nested.txt')..writeAsStringSync('n');
  final plain = File('${syncRoot.path}\\plain.txt')..writeAsStringSync('p');

  final arena = Arena();
  final syncRootPath = syncRoot.path.toNativeUtf16(allocator: arena);
  final subPath = sub.path.toNativeUtf16(allocator: arena);
  final subSubPath = subSub.path.toNativeUtf16(allocator: arena);
  final plainPath = plain.path.toNativeUtf16(allocator: arena);
  var oldMode = -1;
  try {
    final registration = arena<CF_SYNC_REGISTRATION>();
    registration.ref
      ..structSize = sizeOf<CF_SYNC_REGISTRATION>()
      ..providerName = 'ReproProvider'.toNativeUtf16(allocator: arena)
      ..providerVersion = '1.0'.toNativeUtf16(allocator: arena)
      ..syncRootIdentity = (arena<Uint32>()..value = 1).cast()
      ..syncRootIdentityLength = 4
      ..providerId.data1 = 0x52657072;
    final policies = arena<CF_SYNC_POLICIES>()
      ..ref.structSize = sizeOf<CF_SYNC_POLICIES>();
    check(cfRegisterSyncRoot(syncRootPath, registration, policies, 0x2 | 0x4),
        'CfRegisterSyncRoot');

    convertToPlaceholder(subPath);
    convertToPlaceholder(subSubPath);
    convertToPlaceholder(plainPath);

    void report(String phase) {
      print('[$phase] mode=${queryPlaceholderCompatibilityMode()}');
      print(
          '[$phase] reparse attr: sync_root=${hasReparseAttribute(syncRootPath)} '
          'sub=${hasReparseAttribute(subPath)} '
          'sub_sub=${hasReparseAttribute(subSubPath)} '
          'plain.txt=${hasReparseAttribute(plainPath)}');
      print('[$phase] sync_root: ${describe(syncRoot.path)}');
      print('[$phase] sub: ${describe(sub.path)}');
      print('[$phase] plain.txt: ${describe(plain.path)}');
      print('[$phase] temp.listSync(followLinks: false): '
          '${names(temp, recursive: false, followLinks: false)}');
      print('[$phase] sync_root.listSync(followLinks: false): '
          '${names(syncRoot, recursive: false, followLinks: false)}');
      print('[$phase] sync_root recursive/noFollow: '
          '${names(syncRoot, recursive: true, followLinks: false)}');
    }

    report('default');
    oldMode = setPlaceholderCompatibilityMode(2);
    report('exposed');
    try {
      print('[exposed] Link(sub).targetSync(): ${Link(sub.path).targetSync()}');
    } on FileSystemException catch (e) {
      print('[exposed] Link(sub).targetSync(): $e');
    }
    try {
      syncRoot.deleteSync(recursive: true);
      print('[exposed] deleteSync(recursive: true): ok');
    } on FileSystemException catch (e) {
      print('[exposed] deleteSync(recursive: true): $e');
    }
  } finally {
    cfUnregisterSyncRoot(syncRootPath);
    arena.releaseAll();
    if (oldMode >= 0) setPlaceholderCompatibilityMode(oldMode);
    for (final entity in [nested, subSub, plain, sub, syncRoot, temp]) {
      try {
        entity.deleteSync();
      } on FileSystemException catch (_) {}
    }
  }
}

Output on stock Dart 3.13.5 (stable), Windows Server 2025 (Build 26100):

[default] mode=1
[default] reparse attr: sync_root=false sub=false sub_sub=false plain.txt=false
[default] sync_root: typeSync(followLinks: false)=directory typeSync(followLinks: true)=directory isLinkSync=false
[default] sub: typeSync(followLinks: false)=directory typeSync(followLinks: true)=directory isLinkSync=false
[default] plain.txt: typeSync(followLinks: false)=file typeSync(followLinks: true)=file isLinkSync=false
[default] temp.listSync(followLinks: false): [_Link(sync_root)]
[default] sync_root.listSync(followLinks: false): [_Directory(sub), _File(plain.txt)]
[default] sync_root recursive/noFollow: [_Directory(sub), _Directory(sub\sub_sub), _File(plain.txt), _File(sub\sub_sub\nested.txt)]
[exposed] mode=2
[exposed] reparse attr: sync_root=true sub=true sub_sub=true plain.txt=true
[exposed] sync_root: typeSync(followLinks: false)=link typeSync(followLinks: true)=directory isLinkSync=true
[exposed] sub: typeSync(followLinks: false)=link typeSync(followLinks: true)=directory isLinkSync=true
[exposed] plain.txt: typeSync(followLinks: false)=link typeSync(followLinks: true)=file isLinkSync=true
[exposed] temp.listSync(followLinks: false): [_Link(sync_root)]
[exposed] sync_root.listSync(followLinks: false): [_Link(.), _Link(plain.txt), _Link(sub)]
[exposed] sync_root recursive/noFollow: [_Link(.), _Link(plain.txt), _Link(sub)]
[exposed] Link(sub).targetSync(): FileSystemException: Cannot read link, path = 'C:\Users\RUNNER~1\AppData\Local\Temp\placeholders_225f0798\sync_root\sub' (OS Error: The file or directory is not a reparse point, errno = 4390)
[exposed] deleteSync(recursive: true): FileSystemException: Deletion failed, path = 'C:\Users\RUNNER~1\AppData\Local\Temp\placeholders_225f0798\sync_root' (OS Error: The directory is not empty, errno = 145)

(In [exposed] mode, _Link(.) is the dot-entry bug addressed in https://dart-review.googlesource.com/c/sdk/+/560300; _Link(plain.txt) and _Link(sub) remain even after that CL.)

Output with the proposed IsReparseTagNameSurrogate fix (Windows 11 Build 26100):

[default] mode=0
[default] reparse attr: sync_root=false sub=false sub_sub=false plain.txt=false
[default] sync_root: typeSync(followLinks: false)=directory typeSync(followLinks: true)=directory isLinkSync=false
[default] sub: typeSync(followLinks: false)=directory typeSync(followLinks: true)=directory isLinkSync=false
[default] plain.txt: typeSync(followLinks: false)=file typeSync(followLinks: true)=file isLinkSync=false
[default] temp.listSync(followLinks: false): [_Directory(sync_root)]
[default] sync_root.listSync(followLinks: false): [_Directory(sub), _File(plain.txt)]
[default] sync_root recursive/noFollow: [_Directory(sub), _Directory(sub\sub_sub), _File(plain.txt), _File(sub\sub_sub\nested.txt)]
[exposed] mode=2
[exposed] reparse attr: sync_root=true sub=true sub_sub=true plain.txt=true
[exposed] sync_root: typeSync(followLinks: false)=directory typeSync(followLinks: true)=directory isLinkSync=false
[exposed] sub: typeSync(followLinks: false)=directory typeSync(followLinks: true)=directory isLinkSync=false
[exposed] plain.txt: typeSync(followLinks: false)=file typeSync(followLinks: true)=file isLinkSync=false
[exposed] temp.listSync(followLinks: false): [_Directory(sync_root)]
[exposed] sync_root.listSync(followLinks: false): [_Directory(sub), _File(plain.txt)]
[exposed] sync_root recursive/noFollow: [_Directory(sub), _Directory(sub\sub_sub), _File(plain.txt), _File(sub\sub_sub\nested.txt)]
[exposed] Link(sub).targetSync(): FileSystemException: Cannot read link, path = 'C:\b\s\w\ir\x\t\placeholders_6ee951d1\sync_root\sub' (OS Error: The file or directory is not a reparse point, errno = 4390)
[exposed] deleteSync(recursive: true): ok

Proposed fix

Proposed CL: https://dart-review.googlesource.com/c/sdk/+/561560

Check IsReparseTagNameSurrogate(tag) (tag & 0x20000000 != 0) instead of treating every FILE_ATTRIBUTE_REPARSE_POINT as a link:

  • HandleFindFile (runtime/bin/directory_win.cc): find_file_data.dwReserved0 already holds the reparse tag during enumeration. Return kListLink (and run the link-loop check) only when IsReparseTagNameSurrogate(find_file_data.dwReserved0) is true; otherwise fall through to normal directory/file handling.
  • GetTypeAndAttributes (runtime/bin/file_win.cc): already reads the tag via GetReparsePointTag to exclude IO_REPARSE_TAG_AF_UNIX; treat the entity as a link (returning File::kIsLink when !follow_links or calling ResolveReparsePoint when follow_links) only when IsReparseTagNameSurrogate(tag) is true.
  • DeleteLinkHelper (runtime/bin/file_win.cc): verify GetReparsePointTag(path, &tag) && IsReparseTagNameSurrogate(tag) so Link.delete fails with ERROR_NOT_A_REPARSE_POINT on non-surrogate reparse points instead of deleting ordinary files/directories.
  • RecursiveDeleter::DeleteDirectory (runtime/bin/directory_win.cc): only skip recursion for name-surrogate reparse points (junctions and directory symlinks), recursing normally into other reparse-point directories.

Compatibility / breaking change impact

Symbolic links (IO_REPARSE_TAG_SYMLINK), junctions / mount points (IO_REPARSE_TAG_MOUNT_POINT), and Unix domain sockets (IO_REPARSE_TAG_AF_UNIX) are unaffected.

For non-surrogate reparse points (IO_REPARSE_TAG_CLOUD*, IO_REPARSE_TAG_APPEXECLINK, IO_REPARSE_TAG_PROJFS, IO_REPARSE_TAG_DEDUP, IO_REPARSE_TAG_WOF):

  • type(followLinks: false) / isLink / Directory.list(followLinks: false) will report file or directory instead of link. Existing code that received Link instances for these entries could not call Link.target or Link.update on them (both fail with errno = 4390), and Directory.list(recursive: true, followLinks: false) will now traverse into non-surrogate reparse-point directories instead of skipping them.
  • Directory.delete(recursive: true) will recurse into and delete non-empty non-surrogate reparse-point directories instead of failing with ERROR_DIR_NOT_EMPTY (errno = 145). Unlike junctions/symlinks (Recursive directory delete traverses into junctions (symlinks) on Windows #4560), non-surrogate reparse-point directories do not point to an external target directory.
  • type(followLinks: true) / File.exists on %LOCALAPPDATA%\Microsoft\WindowsApps\*.exe (IO_REPARSE_TAG_APPEXECLINK) will return file / true instead of notFound / false. Note that WindowsApps is on PATH by default and includes both real MSIX app aliases (winget.exe, wt.exe) and the built-in 0-byte Microsoft Store redirector stubs (python.exe, python3.exe when Python is not installed), so naive PATH lookups that check only File.existsSync() (without checking file size or executing) will see WindowsApps\python3.exe as existing, matching GetFileAttributesExW, PowerShell Test-Path, and Python/Go/Rust.

Python 3.8+ (bpo-37834), Go 1.23 (golang/go#61893), and Rust (std::fs::symlink_metadata) all use IsReparseTagNameSurrogate for this classification.

Related

Activity

  1. added
    area-vmUse area-vm for VM related issues, including code coverage, and the AOT and JIT backends.
    type-bugIncorrect behavior (everything from a crash to more subtle misbehavior)
    on Oct 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area-vmUse area-vm for VM related issues, including code coverage, and the AOT and JIT backends.library-ioos-windowstype-bugIncorrect behavior (everything from a crash to more subtle misbehavior)

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions