From 57d665f70c8c2c2e4df8939f1c6803acd700e3a0 Mon Sep 17 00:00:00 2001 From: Brian Quinlan Date: Sat, 19 Apr 2025 08:53:21 -0700 Subject: [PATCH 1/4] Add support for `same` on POSIX --- pkgs/io_file/lib/src/file_system.dart | 12 + .../io_file/lib/src/vm_posix_file_system.dart | 17 ++ pkgs/io_file/test/same_test.dart | 224 ++++++++++++++++++ 3 files changed, 253 insertions(+) create mode 100644 pkgs/io_file/test/same_test.dart diff --git a/pkgs/io_file/lib/src/file_system.dart b/pkgs/io_file/lib/src/file_system.dart index ec97b790..c3ccd612 100644 --- a/pkgs/io_file/lib/src/file_system.dart +++ b/pkgs/io_file/lib/src/file_system.dart @@ -30,6 +30,18 @@ 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'); + } + /// Renames, and possibly moves a file system object from one path to another. /// /// If `newPath` is a relative path, it is resolved against the current 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 aaaeeece..365fdefa 100644 --- a/pkgs/io_file/lib/src/vm_posix_file_system.dart +++ b/pkgs/io_file/lib/src/vm_posix_file_system.dart @@ -57,6 +57,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 rename(String oldPath, String newPath) { // See https://pubs.opengroup.org/onlinepubs/000095399/functions/rename.html diff --git a/pkgs/io_file/test/same_test.dart b/pkgs/io_file/test/same_test.dart new file mode 100644 index 00000000..45d12437 --- /dev/null +++ b/pkgs/io_file/test/same_test.dart @@ -0,0 +1,224 @@ +// 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('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); + }); + }); +} From cac2e748f80d25819d2e86a575b6163ef833e0c3 Mon Sep 17 00:00:00 2001 From: Brian Quinlan Date: Mon, 21 Apr 2025 09:29:09 -0700 Subject: [PATCH 2/4] Remove incomplete example. --- pkgs/io_file/lib/src/file_system.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/pkgs/io_file/lib/src/file_system.dart b/pkgs/io_file/lib/src/file_system.dart index c3ccd612..61b17e28 100644 --- a/pkgs/io_file/lib/src/file_system.dart +++ b/pkgs/io_file/lib/src/file_system.dart @@ -37,7 +37,6 @@ base class FileSystem { /// 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'); } From e3ddbd5572e5661153e9bc7a60711b1ff99d2fc0 Mon Sep 17 00:00:00 2001 From: Brian Quinlan Date: Mon, 21 Apr 2025 15:51:52 -0700 Subject: [PATCH 3/4] Update README.md --- pkgs/io_file/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkgs/io_file/README.md b/pkgs/io_file/README.md index b84c1d4c..3f13fd5d 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) | | | | | | | | From 91c54656a350021ca3ed3ccd518ab39a46ba8ac3 Mon Sep 17 00:00:00 2001 From: Brian Quinlan Date: Mon, 28 Apr 2025 10:55:19 -0700 Subject: [PATCH 4/4] Update same_test.dart --- pkgs/io_file/test/same_test.dart | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/pkgs/io_file/test/same_test.dart b/pkgs/io_file/test/same_test.dart index 45d12437..c7082e32 100644 --- a/pkgs/io_file/test/same_test.dart +++ b/pkgs/io_file/test/same_test.dart @@ -174,6 +174,14 @@ void main() { 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';