-
Notifications
You must be signed in to change notification settings - Fork 1.2k
FileManager: Implement contentsEqual(atPath:andPath:) #1510
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
Changes from 1 commit
06add84
c8c2a7b
faba87e
054ed7c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -637,11 +637,161 @@ open class FileManager : NSObject { | |
open func isDeletableFile(atPath path: String) -> Bool { | ||
NSUnimplemented() | ||
} | ||
|
||
|
||
private func _compareFiles(withFileSystemRepresentation file1Rep: UnsafePointer<Int8>, andFileSystemRepresentation file2Rep: UnsafePointer<Int8>, size: Int64) -> Bool { | ||
let bufSize = min(size, 1024 * 1024) | ||
let fd1 = open(file1Rep, O_RDONLY) | ||
guard fd1 >= 0 else { | ||
return false | ||
} | ||
defer { close(fd1) } | ||
|
||
let fd2 = open(file2Rep, O_RDONLY) | ||
guard fd2 >= 0 else { | ||
return false | ||
} | ||
defer { close(fd2) } | ||
|
||
let buffer1 = UnsafeMutablePointer<UInt8>.allocate(capacity: Int(bufSize)) | ||
let buffer2 = UnsafeMutablePointer<UInt8>.allocate(capacity: Int(bufSize)) | ||
defer { | ||
buffer1.deallocate() | ||
buffer2.deallocate() | ||
} | ||
|
||
var bytesLeft = size | ||
while bytesLeft > 0 { | ||
let bytesToRead = Int(min(bufSize, bytesLeft)) | ||
guard read(fd1, buffer1, bytesToRead) == bytesToRead else { | ||
return false | ||
} | ||
guard read(fd2, buffer2, bytesToRead) == bytesToRead else { | ||
return false | ||
} | ||
guard memcmp(buffer1, buffer2, bytesToRead) == 0 else { | ||
return false | ||
} | ||
bytesLeft -= Int64(bytesToRead) | ||
} | ||
return true | ||
} | ||
|
||
private func _compareSymlinks(withFileSystemRepresentation file1Rep: UnsafePointer<Int8>, andFileSystemRepresentation file2Rep: UnsafePointer<Int8>, size: Int64) -> Bool { | ||
let bufSize = Int(size) + 1 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. On Darwin at least, |
||
let buffer1 = UnsafeMutablePointer<CChar>.allocate(capacity: Int(bufSize)) | ||
let buffer2 = UnsafeMutablePointer<CChar>.allocate(capacity: Int(bufSize)) | ||
|
||
let size1 = readlink(file1Rep, buffer1, bufSize) | ||
let size2 = readlink(file2Rep, buffer2, bufSize) | ||
|
||
let compare: Bool | ||
if size1 < 0 || size2 < 0 || size1 != size || size1 != size2 { | ||
compare = false | ||
} else { | ||
compare = memcmp(buffer1, buffer2, size1) == 0 | ||
} | ||
|
||
buffer1.deallocate() | ||
buffer2.deallocate() | ||
return compare | ||
} | ||
|
||
private func _compareDirectories(atPath path1: String, andPath path2: String) -> Bool { | ||
guard let enumerator1 = enumerator(atPath: path1) else { | ||
return false | ||
} | ||
|
||
guard let enumerator2 = enumerator(atPath: path2) else { | ||
return false | ||
} | ||
|
||
var path1entries = Set<String>() | ||
while let item = enumerator1.nextObject() as? String { | ||
path1entries.insert(item) | ||
} | ||
|
||
while let item = enumerator2.nextObject() as? String { | ||
if path1entries.remove(item) == nil { | ||
return false | ||
} | ||
} | ||
return path1entries.isEmpty | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'd expect this to recurse for each item in the two directories. If the contents of 'A/C' doesn't match 'B/C', then this is supposed to fail. |
||
} | ||
|
||
private func _lstatFile(atPath path: String, withFileSystemRepresentation fsRep: UnsafePointer<Int8>? = nil) throws -> stat { | ||
let _fsRep: UnsafePointer<Int8> | ||
if fsRep == nil { | ||
_fsRep = fileSystemRepresentation(withPath: path) | ||
defer { _fsRep.deallocate() } | ||
} else { | ||
_fsRep = fsRep! | ||
} | ||
var statInfo = stat() | ||
guard lstat(_fsRep, &statInfo) == 0 else { | ||
throw _NSErrorWithErrno(errno, reading: true, path: path) | ||
} | ||
return statInfo | ||
} | ||
|
||
/* -contentsEqualAtPath:andPath: does not take into account data stored in the resource fork or filesystem extended attributes. | ||
*/ | ||
open func contentsEqual(atPath path1: String, andPath path2: String) -> Bool { | ||
NSUnimplemented() | ||
let fsRep1 = fileSystemRepresentation(withPath: path1) | ||
defer { fsRep1.deallocate() } | ||
|
||
guard let file1 = try? _lstatFile(atPath: path1, withFileSystemRepresentation: fsRep1) else { | ||
return false | ||
} | ||
let file1Type = file1.st_mode & S_IFMT | ||
|
||
// Dont use access() for symlinks as only the contents should be checked even | ||
// if the symlink doesnt point to an actual file, but access() will always try | ||
// to resolve the link and fail if the destination is not found | ||
if path1 == path2 && file1Type != S_IFLNK { | ||
return access(fsRep1, R_OK) == 0 | ||
} | ||
|
||
let fsRep2 = fileSystemRepresentation(withPath: path2) | ||
defer { fsRep2.deallocate() } | ||
guard let file2 = try? _lstatFile(atPath: path2, withFileSystemRepresentation: fsRep2) else { | ||
return false | ||
} | ||
let file2Type = file2.st_mode & S_IFMT | ||
|
||
// Are paths the same type: file, directory, symbolic link etc. | ||
guard file1Type == file2Type else { | ||
return false | ||
} | ||
|
||
if file1Type == S_IFCHR { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The same could be done here for |
||
// For character devices, just check the major/minor pair is the same. | ||
return _char_dev_major(file1.st_rdev) == _char_dev_major(file2.st_rdev) | ||
&& _char_dev_minor(file1.st_rdev) == _char_dev_minor(file2.st_rdev) | ||
} | ||
|
||
// If both paths point to the same device/inode or they are both zero length | ||
// then they are considered equal so just check readability. | ||
if (file1.st_dev == file2.st_dev && file1.st_ino == file2.st_ino) | ||
|| (file1.st_size == 0 && file2.st_size == 0) { | ||
return access(fsRep1, R_OK) == 0 && access(fsRep2, R_OK) == 0 | ||
} | ||
|
||
if file1Type == S_IFREG { | ||
// Regular files and symlinks should at least have the same filesize if contents are equal. | ||
guard file1.st_size == file2.st_size else { | ||
return false | ||
} | ||
return _compareFiles(withFileSystemRepresentation: path1, andFileSystemRepresentation: path2, size: Int64(file1.st_size)) | ||
} | ||
else if file1Type == S_IFLNK { | ||
return _compareSymlinks(withFileSystemRepresentation: fsRep1, andFileSystemRepresentation: fsRep2, size: Int64(file1.st_size)) | ||
} | ||
else if file1Type == S_IFDIR { | ||
return _compareDirectories(atPath: path1, andPath: path2) | ||
} | ||
|
||
// Dont know how to compare other file types. | ||
return false | ||
} | ||
|
||
/* displayNameAtPath: returns an NSString suitable for presentation to the user. For directories which have localization information, this will return the appropriate localized string. This string is not suitable for passing to anything that must interact with the filesystem. | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -34,7 +34,8 @@ class TestFileManager : XCTestCase { | |
("test_homedirectoryForUser", test_homedirectoryForUser), | ||
("test_temporaryDirectoryForUser", test_temporaryDirectoryForUser), | ||
("test_creatingDirectoryWithShortIntermediatePath", test_creatingDirectoryWithShortIntermediatePath), | ||
("test_mountedVolumeURLs", test_mountedVolumeURLs) | ||
("test_mountedVolumeURLs", test_mountedVolumeURLs), | ||
("test_contentsEqual", test_contentsEqual) | ||
] | ||
} | ||
|
||
|
@@ -620,4 +621,83 @@ class TestFileManager : XCTestCase { | |
XCTAssertTrue(visibleVolumes.count < volumes.count) | ||
#endif | ||
} | ||
|
||
func test_contentsEqual() { | ||
let fm = FileManager.default | ||
let tmpParentDirURL = URL(fileURLWithPath: NSTemporaryDirectory() + "test_contentsEqualdir", isDirectory: true) | ||
let testDir1 = tmpParentDirURL.appendingPathComponent("testDir1") | ||
let testDir2 = tmpParentDirURL.appendingPathComponent("testDir2") | ||
let testDir3 = testDir1.appendingPathComponent("subDir") | ||
|
||
defer { try? fm.removeItem(atPath: tmpParentDirURL.path) } | ||
|
||
func testFileURL(_ name: String, _ ext: String) -> URL? { | ||
guard let url = testBundle().url(forResource: name, withExtension: ext) else { | ||
XCTFail("Cant open \(name).\(ext)") | ||
return nil | ||
} | ||
return url | ||
} | ||
|
||
guard let testFile1URL = testFileURL("NSStringTestData", "txt") else { return } | ||
guard let testFile2URL = testFileURL("NSURLTestData", "plist") else { return } | ||
guard let testFile3URL = testFileURL("NSString-UTF32-BE-data", "txt") else { return } | ||
guard let testFile4URL = testFileURL("NSString-UTF32-LE-data", "txt") else { return } | ||
let symlink = testDir1.appendingPathComponent("testlink").path | ||
|
||
// Setup test directories | ||
do { | ||
// Clean out and leftover test data | ||
try? fm.removeItem(atPath: tmpParentDirURL.path) | ||
|
||
// testDir1 | ||
try fm.createDirectory(atPath: testDir1.path, withIntermediateDirectories: true) | ||
try fm.createSymbolicLink(atPath: testDir1.appendingPathComponent("null1").path, withDestinationPath: "/dev/null") | ||
try fm.createSymbolicLink(atPath: testDir1.appendingPathComponent("zero1").path, withDestinationPath: "/dev/zero") | ||
try "foo".write(toFile: testDir1.appendingPathComponent("foo.txt").path, atomically: false, encoding: .ascii) | ||
try fm.createSymbolicLink(atPath: testDir1.appendingPathComponent("foo1").path, withDestinationPath: "foo.txt") | ||
try fm.createSymbolicLink(atPath: testDir1.appendingPathComponent("bar2").path, withDestinationPath: "foo1") | ||
let unreadable = testDir1.appendingPathComponent("unreadable_file").path | ||
try "unreadable".write(toFile: unreadable, atomically: false, encoding: .ascii) | ||
try fm.setAttributes([.posixPermissions: NSNumber(value: 0)], ofItemAtPath: unreadable) | ||
try Data().write(to: testDir1.appendingPathComponent("empty_file")) | ||
try fm.createSymbolicLink(atPath: symlink, withDestinationPath: testFile1URL.path) | ||
|
||
// testDir2 | ||
try fm.createDirectory(atPath: testDir2.path, withIntermediateDirectories: true) | ||
try fm.createSymbolicLink(atPath: testDir2.appendingPathComponent("bar2").path, withDestinationPath: "foo1") | ||
try fm.createSymbolicLink(atPath: testDir2.appendingPathComponent("foo2").path, withDestinationPath: "../testDir1/foo.txt") | ||
|
||
// testDir3 | ||
try fm.createDirectory(atPath: testDir3.path, withIntermediateDirectories: true) | ||
try fm.createSymbolicLink(atPath: testDir3.appendingPathComponent("bar2").path, withDestinationPath: "foo1") | ||
try fm.createSymbolicLink(atPath: testDir3.appendingPathComponent("foo2").path, withDestinationPath: "../testDir1/foo.txt") | ||
} catch { | ||
XCTFail(String(describing: error)) | ||
} | ||
|
||
XCTAssertTrue(fm.contentsEqual(atPath: "/dev/null", andPath: "/dev/null")) | ||
XCTAssertTrue(fm.contentsEqual(atPath: "/dev/urandom", andPath: "/dev/urandom")) | ||
XCTAssertFalse(fm.contentsEqual(atPath: "/dev/null", andPath: "/dev/zero")) | ||
XCTAssertFalse(fm.contentsEqual(atPath: testDir1.appendingPathComponent("null1").path, andPath: "/dev/null")) | ||
XCTAssertFalse(fm.contentsEqual(atPath: testDir1.appendingPathComponent("zero").path, andPath: "/dev/zero")) | ||
XCTAssertFalse(fm.contentsEqual(atPath: testDir1.appendingPathComponent("foo.txt").path, andPath: testDir1.appendingPathComponent("foo1").path)) | ||
XCTAssertFalse(fm.contentsEqual(atPath: testDir1.appendingPathComponent("foo.txt").path, andPath: testDir1.appendingPathComponent("foo2").path)) | ||
XCTAssertTrue(fm.contentsEqual(atPath: testDir1.appendingPathComponent("bar2").path, andPath: testDir2.appendingPathComponent("bar2").path)) | ||
XCTAssertFalse(fm.contentsEqual(atPath: testDir1.appendingPathComponent("foo1").path, andPath: testDir2.appendingPathComponent("foo2").path)) | ||
XCTAssertFalse(fm.contentsEqual(atPath: "/non_existant_file", andPath: "/non_existant_file")) | ||
|
||
let emptyFile = testDir1.appendingPathComponent("empty_file") | ||
XCTAssertFalse(fm.contentsEqual(atPath: emptyFile.path, andPath: "/dev/null")) | ||
XCTAssertFalse(fm.contentsEqual(atPath: emptyFile.path, andPath: testDir1.appendingPathComponent("null1").path)) | ||
XCTAssertFalse(fm.contentsEqual(atPath: emptyFile.path, andPath: testDir1.appendingPathComponent("unreadable_file").path)) | ||
|
||
XCTAssertTrue(fm.contentsEqual(atPath: testFile1URL.path, andPath: testFile1URL.path)) | ||
XCTAssertFalse(fm.contentsEqual(atPath: testFile1URL.path, andPath: testFile2URL.path)) | ||
XCTAssertFalse(fm.contentsEqual(atPath: testFile3URL.path, andPath: testFile4URL.path)) | ||
XCTAssertFalse(fm.contentsEqual(atPath: symlink, andPath: testFile1URL.path)) | ||
XCTAssertTrue(fm.contentsEqual(atPath: testDir1.path, andPath: testDir1.path)) | ||
XCTAssertTrue(fm.contentsEqual(atPath: testDir2.path, andPath: testDir3.path)) | ||
XCTAssertFalse(fm.contentsEqual(atPath: testDir1.path, andPath: testDir2.path)) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. A test case that verifies recursive (in)equality is needed. |
||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It might be worth using
st_blksize
to pick an optimal block size. 1 MB (*2) seems like a rather large allocation.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Im not sure how to relate it to block size, given there are only a few fixed block sizes. How does Darwin's Foundation decide on the buffer size to use?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
st_blksize
is documented as "The optimal I/O block size for the file". I've taken that to mean that, especially if we're not reading the entire contents of the file into a single buffer (a la NSData), that this value is the system-declared preferred buffer size for I/O operations. I won't link directly to any here, but various implementations of 'cat' agree (including Darwin's).Darwin's implementation of this method is fairly old and happens not to use this value—opting for a constant 8K size instead—though it should probably should.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah right, I mis-read this as using some multiple of
st_blksize
rather than the value directly.