Skip to content

Commit 139c7f7

Browse files
authored
Implement writeAsBytes for POSIX (#206)
1 parent 5645386 commit 139c7f7

File tree

3 files changed

+318
-1
lines changed

3 files changed

+318
-1
lines changed

pkgs/io_file/lib/src/file_system.dart

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,30 @@
44

55
import 'dart:typed_data';
66

7+
/// The modes in which a File can be written.
8+
class WriteMode {
9+
/// Open the file for writing such that data can only be appended to the end
10+
/// of it. The file is created if it does not already exist.
11+
static const appendExisting = WriteMode._(1);
12+
13+
/// Open the file for writing and discard any existing data in the file.
14+
/// The file is created if it does not already exist.
15+
static const truncateExisting = WriteMode._(2);
16+
17+
/// Open the file for writing and file with a `PathExistsException` if the
18+
/// file already exists.
19+
static const failExisting = WriteMode._(3);
20+
21+
final int _mode;
22+
const WriteMode._(this._mode);
23+
24+
@override
25+
bool operator ==(Object other) => other is WriteMode && _mode == other._mode;
26+
27+
@override
28+
int get hashCode => _mode.hashCode;
29+
}
30+
731
/// An abstract representation of a file system.
832
base class FileSystem {
933
/// Renames, and possibly moves a file system object from one path to another.
@@ -30,4 +54,13 @@ base class FileSystem {
3054
Uint8List readAsBytes(String path) {
3155
throw UnsupportedError('readAsBytes');
3256
}
57+
58+
/// Write the given bytes to a file.
59+
void writeAsBytes(
60+
String path,
61+
Uint8List data, [
62+
WriteMode mode = WriteMode.failExisting,
63+
]) {
64+
throw UnsupportedError('writeAsBytes');
65+
}
3366
}

pkgs/io_file/lib/src/vm_posix_file_system.dart

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@ import 'package:stdlibc/stdlibc.dart' as stdlibc;
1313
import 'file_system.dart';
1414
import 'internal_constants.dart';
1515

16+
const _maxInt32 = 2147483647;
17+
18+
/// The default `mode` to use with `open` calls that may create a file.
19+
const _defaultMode = 438; // => 0666 => rw-rw-rw-
20+
1621
Exception _getError(int errno, String message, String path) {
1722
//TODO(brianquinlan): In the long-term, do we need to avoid exceptions that
1823
// are part of `dart:io`? Can we move those exceptions into a different
@@ -45,6 +50,12 @@ int _tempFailureRetry(int Function() f) {
4550
@Native<Int Function(Int, Pointer<Uint8>, Int)>(isLeaf: false)
4651
external int read(int fd, Pointer<Uint8> buf, int count);
4752

53+
/// The POSIX `write` function.
54+
///
55+
/// See https://pubs.opengroup.org/onlinepubs/9699919799/functions/write.html
56+
@Native<Int Function(Int, Pointer<Uint8>, Int)>(isLeaf: false)
57+
external int write(int fd, Pointer<Uint8> buf, int count);
58+
4859
/// A [FileSystem] implementation for POSIX systems (e.g. Android, iOS, Linux,
4960
/// macOS).
5061
base class PosixFileSystem extends FileSystem {
@@ -59,7 +70,9 @@ base class PosixFileSystem extends FileSystem {
5970

6071
@override
6172
Uint8List readAsBytes(String path) {
62-
final fd = stdlibc.open(path, flags: stdlibc.O_RDONLY | stdlibc.O_CLOEXEC);
73+
final fd = _tempFailureRetry(
74+
() => stdlibc.open(path, flags: stdlibc.O_RDONLY | stdlibc.O_CLOEXEC),
75+
);
6376
if (fd == -1) {
6477
final errno = stdlibc.errno;
6578
throw _getError(errno, 'open failed', path);
@@ -134,4 +147,58 @@ base class PosixFileSystem extends FileSystem {
134147
rethrow;
135148
}
136149
}
150+
151+
@override
152+
void writeAsBytes(
153+
String path,
154+
Uint8List data, [
155+
WriteMode mode = WriteMode.failExisting,
156+
]) {
157+
var flags = stdlibc.O_WRONLY | stdlibc.O_CLOEXEC;
158+
159+
flags |= switch (mode) {
160+
WriteMode.appendExisting => stdlibc.O_CREAT | stdlibc.O_APPEND,
161+
WriteMode.failExisting => stdlibc.O_CREAT | stdlibc.O_EXCL,
162+
WriteMode.truncateExisting => stdlibc.O_CREAT | stdlibc.O_TRUNC,
163+
_ => throw ArgumentError.value(mode, 'invalid write mode'),
164+
};
165+
166+
final fd = _tempFailureRetry(
167+
() => stdlibc.open(path, flags: flags, mode: _defaultMode),
168+
);
169+
try {
170+
if (fd == -1) {
171+
final errno = stdlibc.errno;
172+
throw _getError(errno, 'open failed', path);
173+
}
174+
175+
ffi.using((arena) {
176+
// TODO(brianquinlan): Remove this copy when typed data pointers are
177+
// available for non-leaf calls.
178+
var buffer = arena<Uint8>(data.length);
179+
buffer.asTypedList(data.length).setAll(0, data);
180+
var remaining = data.length;
181+
182+
// `write` on FreeBSD returns `EINVAL` if nbytes is greater than
183+
// `INT_MAX`.
184+
// See https://man.freebsd.org/cgi/man.cgi?write(2)
185+
final maxWriteSize =
186+
(io.Platform.isIOS || io.Platform.isMacOS) ? _maxInt32 : remaining;
187+
188+
while (remaining > 0) {
189+
final w = _tempFailureRetry(
190+
() => write(fd, buffer, min(remaining, maxWriteSize)),
191+
);
192+
if (w == -1) {
193+
final errno = stdlibc.errno;
194+
throw _getError(errno, 'write failed', path);
195+
}
196+
remaining -= w;
197+
buffer += w;
198+
}
199+
});
200+
} finally {
201+
stdlibc.close(fd);
202+
}
203+
}
137204
}
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
// Copyright (c) 2025, the Dart project authors. Please see the AUTHORS file
2+
// for details. All rights reserved. Use of this source code is governed by a
3+
// BSD-style license that can be found in the LICENSE file.
4+
5+
@TestOn('posix')
6+
library;
7+
8+
import 'dart:io';
9+
import 'dart:typed_data';
10+
11+
import 'package:io_file/io_file.dart';
12+
import 'package:stdlibc/stdlibc.dart' as stdlibc;
13+
import 'package:test/test.dart';
14+
15+
import 'test_utils.dart';
16+
17+
void main() {
18+
//TODO(brianquinlan): test with a very long path.
19+
20+
group('writeAsBytes', () {
21+
late String tmp;
22+
23+
setUp(() => tmp = createTemp('writeAsBytes'));
24+
25+
tearDown(() => deleteTemp(tmp));
26+
27+
test('directory', () {
28+
expect(
29+
() => fileSystem.writeAsBytes(
30+
tmp,
31+
Uint8List(5),
32+
WriteMode.truncateExisting,
33+
),
34+
throwsA(
35+
isA<FileSystemException>()
36+
.having((e) => e.message, 'message', 'open failed')
37+
.having((e) => e.osError?.errorCode, 'errorCode', stdlibc.EISDIR)
38+
.having((e) => e.path, 'path', tmp),
39+
),
40+
);
41+
});
42+
43+
test('symlink', () {
44+
final filePath = '$tmp/file1';
45+
final symlinkPath = '$tmp/file2';
46+
final data = randomUint8List(20);
47+
File(filePath).writeAsBytesSync(Uint8List(0));
48+
Link(symlinkPath).createSync(filePath);
49+
50+
fileSystem.writeAsBytes(symlinkPath, data, WriteMode.truncateExisting);
51+
52+
expect(fileSystem.readAsBytes(symlinkPath), data);
53+
});
54+
55+
group('broken symlink', () {
56+
test('failExisting', () {
57+
final filePath = '$tmp/file1';
58+
final symlinkPath = '$tmp/file2';
59+
final data = randomUint8List(20);
60+
File(filePath).writeAsBytesSync(Uint8List(0));
61+
Link(symlinkPath).createSync(filePath);
62+
File(filePath).deleteSync();
63+
64+
expect(
65+
() => fileSystem.writeAsBytes(
66+
symlinkPath,
67+
Uint8List.fromList(data),
68+
WriteMode.failExisting,
69+
),
70+
throwsA(
71+
isA<PathExistsException>()
72+
.having((e) => e.message, 'message', 'open failed')
73+
.having(
74+
(e) => e.osError?.errorCode,
75+
'errorCode',
76+
stdlibc.EEXIST,
77+
)
78+
.having((e) => e.path, 'path', symlinkPath),
79+
),
80+
);
81+
});
82+
test('truncateExisting', () {
83+
final filePath = '$tmp/file1';
84+
final symlinkPath = '$tmp/file2';
85+
final data = randomUint8List(20);
86+
File(filePath).writeAsBytesSync(Uint8List(0));
87+
Link(symlinkPath).createSync(filePath);
88+
File(filePath).deleteSync();
89+
90+
fileSystem.writeAsBytes(symlinkPath, data, WriteMode.truncateExisting);
91+
92+
// Should write at the symlink target, which should also mean that the
93+
// symlink is no longer broken.
94+
expect(fileSystem.readAsBytes(symlinkPath), data);
95+
expect(fileSystem.readAsBytes(filePath), data);
96+
});
97+
});
98+
99+
group('new file', () {
100+
test('appendExisting', () {
101+
final data = randomUint8List(20);
102+
final path = '$tmp/file';
103+
104+
fileSystem.writeAsBytes(
105+
path,
106+
Uint8List.fromList(data),
107+
WriteMode.appendExisting,
108+
);
109+
110+
expect(File(path).readAsBytesSync(), data);
111+
});
112+
113+
test('failExisting', () {
114+
final data = randomUint8List(20);
115+
final path = '$tmp/file';
116+
117+
fileSystem.writeAsBytes(
118+
path,
119+
Uint8List.fromList(data),
120+
WriteMode.failExisting,
121+
);
122+
123+
expect(File(path).readAsBytesSync(), data);
124+
});
125+
126+
test('truncateExisting', () {
127+
final data = randomUint8List(20);
128+
final path = '$tmp/file';
129+
130+
fileSystem.writeAsBytes(
131+
path,
132+
Uint8List.fromList(data),
133+
WriteMode.truncateExisting,
134+
);
135+
136+
expect(File(path).readAsBytesSync(), data);
137+
});
138+
});
139+
140+
group('existing file', () {
141+
test('appendExisting', () {
142+
final data = randomUint8List(20);
143+
final path = '$tmp/file';
144+
File(path).writeAsBytesSync([1, 2, 3]);
145+
146+
fileSystem.writeAsBytes(
147+
path,
148+
Uint8List.fromList(data),
149+
WriteMode.appendExisting,
150+
);
151+
152+
expect(File(path).readAsBytesSync(), [1, 2, 3] + data);
153+
});
154+
155+
test('failExisting', () {
156+
final data = randomUint8List(20);
157+
final path = '$tmp/file';
158+
File(path).writeAsBytesSync([1, 2, 3]);
159+
160+
expect(
161+
() => fileSystem.writeAsBytes(path, data, WriteMode.failExisting),
162+
throwsA(
163+
isA<PathExistsException>()
164+
.having((e) => e.message, 'message', 'open failed')
165+
.having(
166+
(e) => e.osError?.errorCode,
167+
'errorCode',
168+
stdlibc.EEXIST,
169+
)
170+
.having((e) => e.path, 'path', path),
171+
),
172+
);
173+
});
174+
175+
test('truncateExisting', () {
176+
final data = randomUint8List(20);
177+
final path = '$tmp/file';
178+
File(path).writeAsBytesSync([1, 2, 3]);
179+
180+
fileSystem.writeAsBytes(path, data, WriteMode.truncateExisting);
181+
182+
expect(File(path).readAsBytesSync(), data);
183+
});
184+
});
185+
186+
group('regular files', () {
187+
for (var i = 0; i <= 1024; ++i) {
188+
test('Write small file: $i bytes', () {
189+
final data = randomUint8List(i);
190+
final path = '$tmp/file';
191+
192+
fileSystem.writeAsBytes(path, data);
193+
expect(fileSystem.readAsBytes(path), data);
194+
});
195+
}
196+
197+
for (var i = 1 << 12; i <= 1 << 30; i <<= 4) {
198+
test('Write large file: $i bytes', () {
199+
final data = randomUint8List(i);
200+
final path = '$tmp/file';
201+
202+
fileSystem.writeAsBytes(path, data);
203+
expect(fileSystem.readAsBytes(path), data);
204+
});
205+
}
206+
207+
test('Write very large file', () {
208+
// FreeBSD cannot write more than INT_MAX at once.
209+
final data = randomUint8List(1 << 31 + 1);
210+
final path = '$tmp/file';
211+
212+
fileSystem.writeAsBytes(path, data);
213+
expect(fileSystem.readAsBytes(path), data);
214+
}, skip: 'very slow');
215+
});
216+
});
217+
}

0 commit comments

Comments
 (0)