diff --git a/pkgs/io_file/README.md b/pkgs/io_file/README.md index 30beee01..92ea8b1a 100644 --- a/pkgs/io_file/README.md +++ b/pkgs/io_file/README.md @@ -23,7 +23,7 @@ See | enum dir contents | | | | | | | | | exists | | | | | | | | | get metadata (stat) | | | | | | | | -| identity (same file) | | | | | | | | +| identity (same file) | | ✓ | ✓ | ✓ | | | | | open | | | | | | | | | read file (bytes) | | ✓ | ✓ | ✓ | ✓ | | | | read file (lines) | | | | | | | | diff --git a/pkgs/io_file/lib/src/file_system.dart b/pkgs/io_file/lib/src/file_system.dart index 15ad1346..aa16b51b 100644 --- a/pkgs/io_file/lib/src/file_system.dart +++ b/pkgs/io_file/lib/src/file_system.dart @@ -34,6 +34,17 @@ class WriteMode { /// An abstract representation of a file system. base class FileSystem { + /// Checks whether two paths refer to the same object in the file system. + /// + /// Throws `PathNotFoundException` if either path doesn't exist. + /// + /// Links are resolved before determining if the paths refer to the same + /// object. Throws `PathNotFoundException` if either path requires resolving + /// a broken link. + bool same(String path1, String path2) { + throw UnsupportedError('same'); + } + /// Create a directory at the given path. /// /// If the directory already exists, then `PathExistsException` is thrown. diff --git a/pkgs/io_file/lib/src/vm_posix_file_system.dart b/pkgs/io_file/lib/src/vm_posix_file_system.dart index a419728a..feab560e 100644 --- a/pkgs/io_file/lib/src/vm_posix_file_system.dart +++ b/pkgs/io_file/lib/src/vm_posix_file_system.dart @@ -60,6 +60,23 @@ external int write(int fd, Pointer buf, int count); /// A [FileSystem] implementation for POSIX systems (e.g. Android, iOS, Linux, /// macOS). base class PosixFileSystem extends FileSystem { + @override + bool same(String path1, String path2) { + final stat1 = stdlibc.stat(path1); + if (stat1 == null) { + final errno = stdlibc.errno; + throw _getError(errno, 'stat failed', path1); + } + + final stat2 = stdlibc.stat(path2); + if (stat2 == null) { + final errno = stdlibc.errno; + throw _getError(errno, 'stat failed', path2); + } + + return (stat1.st_ino == stat2.st_ino) && (stat1.st_dev == stat2.st_dev); + } + @override void createDirectory(String path) { if (stdlibc.mkdir(path, _defaultDirectoryMode) == -1) { diff --git a/pkgs/io_file/test/same_test.dart b/pkgs/io_file/test/same_test.dart new file mode 100644 index 00000000..c7082e32 --- /dev/null +++ b/pkgs/io_file/test/same_test.dart @@ -0,0 +1,232 @@ +// Copyright (c) 2025, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +@TestOn('posix') +library; + +import 'dart:io'; + +import 'package:io_file/io_file.dart'; +import 'package:stdlibc/stdlibc.dart' as stdlibc; +import 'package:test/test.dart'; + +import 'test_utils.dart'; + +void main() { + group('same', () { + late String tmp; + late Directory cwd; + + setUp(() { + tmp = createTemp('same'); + cwd = Directory.current; + }); + + tearDown(() { + Directory.current = cwd; + deleteTemp(tmp); + }); + + //TODO(brianquinlan): test with a very long path. + + test('path1 does not exist', () { + final path1 = '$tmp/file1'; + final path2 = '$tmp/file2'; + File(path2).writeAsStringSync('Hello World'); + + expect( + () => fileSystem.same(path1, path2), + throwsA( + isA() + .having((e) => e.message, 'message', 'stat failed') + .having((e) => e.path, 'path', path1) + .having((e) => e.osError?.errorCode, 'errorCode', stdlibc.ENOENT), + ), + ); + }); + + test('path2 does not exist', () { + final path1 = '$tmp/file1'; + final path2 = '$tmp/file2'; + File(path1).writeAsStringSync('Hello World'); + + expect( + () => fileSystem.same(path1, path2), + throwsA( + isA() + .having((e) => e.message, 'message', 'stat failed') + .having((e) => e.path, 'path', path2) + .having((e) => e.osError?.errorCode, 'errorCode', stdlibc.ENOENT), + ), + ); + }); + + test('path1 and path2 same, do not exist', () { + final path1 = '$tmp/file1'; + final path2 = '$tmp/file1'; + + expect( + () => fileSystem.same(path1, path2), + throwsA( + isA() + .having((e) => e.message, 'message', 'stat failed') + .having((e) => e.path, 'path', path1) + .having((e) => e.osError?.errorCode, 'errorCode', stdlibc.ENOENT), + ), + ); + }); + + test('path1 is a broken symlink', () { + final path1 = '$tmp/file1'; + final path2 = '$tmp/file2'; + Link(path1).createSync('$tmp/file3'); + File(path2).writeAsStringSync('Hello World'); + + expect( + () => fileSystem.same(path1, path2), + throwsA( + isA() + .having((e) => e.message, 'message', 'stat failed') + .having((e) => e.path, 'path', path1) + .having((e) => e.osError?.errorCode, 'errorCode', stdlibc.ENOENT), + ), + ); + }); + + test('path2 is a broken symlink', () { + final path1 = '$tmp/file1'; + final path2 = '$tmp/file2'; + File(path1).writeAsStringSync('Hello World'); + Link(path2).createSync('$tmp/file3'); + + expect( + () => fileSystem.same(path1, path2), + throwsA( + isA() + .having((e) => e.message, 'message', 'stat failed') + .having((e) => e.path, 'path', path2) + .having((e) => e.osError?.errorCode, 'errorCode', stdlibc.ENOENT), + ), + ); + }); + + test('path1 and path2 same, broken symlinks', () { + final path1 = '$tmp/file1'; + final path2 = '$tmp/file1'; + Link(path1).createSync('$tmp/file3'); + + expect( + () => fileSystem.same(path1, path2), + throwsA( + isA() + .having((e) => e.message, 'message', 'stat failed') + .having((e) => e.path, 'path', path1) + .having((e) => e.osError?.errorCode, 'errorCode', stdlibc.ENOENT), + ), + ); + }); + + test('different files, same content', () { + final path1 = '$tmp/file1'; + final path2 = '$tmp/file2'; + File(path1).writeAsStringSync('Hello World'); + File(path2).writeAsStringSync('Hello World'); + + expect(fileSystem.same(path1, path2), isFalse); + }); + + test('same file, absolute and relative paths', () { + Directory.current = tmp; + final path1 = '$tmp/file1'; + const path2 = 'file1'; + File(path1).writeAsStringSync('Hello World'); + + expect(fileSystem.same(path1, path2), isTrue); + }); + + test('file path1, symlink path2', () { + final path1 = '$tmp/file1'; + final path2 = '$tmp/file2'; + File(path1).writeAsStringSync('Hello World'); + Link(path2).createSync(path1); + + expect(fileSystem.same(path1, path2), isTrue); + }); + + test('file symlink path1, symlink path2', () { + final path1 = '$tmp/file1'; + final path2 = '$tmp/file2'; + File('$tmp/file3').writeAsStringSync('Hello World'); + Link(path1).createSync('$tmp/file3'); + Link(path2).createSync('$tmp/file3'); + + expect(fileSystem.same(path1, path2), isTrue); + }); + + test('files through intermediate symlinks', () { + Directory('$tmp/subdir').createSync(); + Link('$tmp/link-to-subdir').createSync('$tmp/subdir'); + final path1 = '$tmp/subdir/file1'; + final path2 = '$tmp/link-to-subdir/file1'; + File(path1).writeAsStringSync('Hello World'); + + expect(fileSystem.same(path1, path2), isTrue); + }); + + test('hard links to same file', () { + final path1 = '$tmp/file1'; + final path2 = '$tmp/file2'; + File(path1).writeAsStringSync('Hello World'); + stdlibc.link(path1, path2); + expect(fileSystem.same(path1, path2), isTrue); + }); + + test('different directories, same content', () { + final path1 = '$tmp/dir1'; + final path2 = '$tmp/dir2'; + Directory(path1).createSync(); + Directory(path2).createSync(); + + expect(fileSystem.same(path1, path2), isFalse); + }); + + test('same directory, absolute and relative paths', () { + Directory.current = tmp; + final path1 = '$tmp/dir1'; + const path2 = 'dir1'; + Directory(path1).createSync(); + + expect(fileSystem.same(path1, path2), isTrue); + }); + + test('directory path1, symlink path2', () { + final path1 = '$tmp/dir1'; + final path2 = '$tmp/dir2'; + Directory(path1).createSync(); + Link(path2).createSync(path1); + + expect(fileSystem.same(path1, path2), isTrue); + }); + + test('directory symlink path1, symlink path2', () { + final path1 = '$tmp/dir1'; + final path2 = '$tmp/dir2'; + Directory('$tmp/dir3').createSync(); + Link(path1).createSync('$tmp/dir3'); + Link(path2).createSync('$tmp/dir3'); + + expect(fileSystem.same(path1, path2), isTrue); + }); + + test('directories through intermediate symlinks', () { + Directory('$tmp/subdir').createSync(); + Link('$tmp/link-to-subdir').createSync('$tmp/subdir'); + final path1 = '$tmp/subdir/dir1'; + final path2 = '$tmp/link-to-subdir/dir1'; + Directory(path1).createSync(); + + expect(fileSystem.same(path1, path2), isTrue); + }); + }); +}