Skip to content

Add support for same on POSIX #211

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pkgs/io_file/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) | | | | | | | |
Expand Down
11 changes: 11 additions & 0 deletions pkgs/io_file/lib/src/file_system.dart
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,17 @@ class WriteMode {

/// An abstract representation of a file system.
base class FileSystem {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Make this class abstract?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We've been discussing this at #202

I'll leave it unchanged here and hopefully we can come to a conclusion in that PR ;-)

/// 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');
}

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe just not have this method, if it's not supported on all file systems.
Only have it on PosixFileSystem.

If you have to do is PosixFileSystem before you can use it safely, better just have to do that before being able to use it at all.

If you plan to implement it on all file systems, make it abstract here, to force an implementation.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It will be implemented on all file systems.

/// 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
Expand Down
17 changes: 17 additions & 0 deletions pkgs/io_file/lib/src/vm_posix_file_system.dart
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,23 @@ external int write(int fd, Pointer<Uint8> 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);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks like it equates filled with the same inode. That means that hard links, non-symbolic links, to the same inode should be equal, even if they resolve to filled with different paths.

Should be tested too:

  • two files linked to same inode.
  • two different paths linked through hard links to the same directory.

Generally, test with targets that are directories too.

Do symlinks have inodes?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks like it equates filled with the same inode. That means that hard links, non-symbolic links, to the same inode should be equal, even if they resolve to filled with different paths.

Yep.

Should be tested too:

  • two files linked to same inode.
  • two different paths linked through hard links to the same directory.

Will do. I'll need to make some upstream changes to package:stdlibc.

Generally, test with targets that are directories too.

I already do this.

Do symlinks have inodes?

Yes, but same resolves the symlink before checking the inode.

FileSystemEntity.identical does not resolve the final symlink but same does. For example:

Link('foo').create('bar');
FileSystemEntity.identical('foo', 'bar'); // false
same('foo', 'bar'); // true

I was thinking that maybe same should be called equivalent and then I could have an identical that behaves the same was as FileSystemEntity.identical. WDYT?

}

@override
void rename(String oldPath, String newPath) {
// See https://pubs.opengroup.org/onlinepubs/000095399/functions/rename.html
Expand Down
224 changes: 224 additions & 0 deletions pkgs/io_file/test/same_test.dart
Original file line number Diff line number Diff line change
@@ -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<PathNotFoundException>()
.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<PathNotFoundException>()
.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<PathNotFoundException>()
.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<PathNotFoundException>()
.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<PathNotFoundException>()
.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<PathNotFoundException>()
.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);
});
});
}
Loading