1// Copyright 2014 The Flutter Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5import 'dart:ffi' show Abi;
6
7import 'package:archive/archive.dart';
8import 'package:file/file.dart';
9import 'package:meta/meta.dart';
10import 'package:process/process.dart';
11
12import 'common.dart';
13import 'file_system.dart';
14import 'io.dart';
15import 'logger.dart';
16import 'platform.dart';
17import 'process.dart';
18
19abstract class OperatingSystemUtils {
20 factory OperatingSystemUtils({
21 required FileSystem fileSystem,
22 required Logger logger,
23 required Platform platform,
24 required ProcessManager processManager,
25 }) {
26 if (platform.isWindows) {
27 return _WindowsUtils(
28 fileSystem: fileSystem,
29 logger: logger,
30 platform: platform,
31 processManager: processManager,
32 );
33 } else if (platform.isMacOS) {
34 return _MacOSUtils(
35 fileSystem: fileSystem,
36 logger: logger,
37 platform: platform,
38 processManager: processManager,
39 );
40 } else if (platform.isLinux) {
41 return _LinuxUtils(
42 fileSystem: fileSystem,
43 logger: logger,
44 platform: platform,
45 processManager: processManager,
46 );
47 } else {
48 return _PosixUtils(
49 fileSystem: fileSystem,
50 logger: logger,
51 platform: platform,
52 processManager: processManager,
53 );
54 }
55 }
56
57 OperatingSystemUtils._private({
58 required FileSystem fileSystem,
59 required Logger logger,
60 required Platform platform,
61 required ProcessManager processManager,
62 }) : _fileSystem = fileSystem,
63 _logger = logger,
64 _platform = platform,
65 _processManager = processManager,
66 _processUtils = ProcessUtils(logger: logger, processManager: processManager);
67
68 @visibleForTesting
69 static final gzipLevel1 = GZipCodec(level: 1);
70
71 final FileSystem _fileSystem;
72 final Logger _logger;
73 final Platform _platform;
74 final ProcessManager _processManager;
75 final ProcessUtils _processUtils;
76
77 /// Make the given file executable. This may be a no-op on some platforms.
78 void makeExecutable(File file);
79
80 /// Updates the specified file system [entity] to have the file mode
81 /// bits set to the value defined by [mode], which can be specified in octal
82 /// (e.g. `644`) or symbolically (e.g. `u+x`).
83 ///
84 /// On operating systems that do not support file mode bits, this will be a
85 /// no-op.
86 void chmod(FileSystemEntity entity, String mode);
87
88 /// Return the path (with symlinks resolved) to the given executable, or null
89 /// if `which` was not able to locate the binary.
90 File? which(String execName) {
91 final List<File> result = _which(execName);
92 if (result.isEmpty) {
93 return null;
94 }
95 return result.first;
96 }
97
98 /// Return a list of all paths to `execName` found on the system. Uses the
99 /// PATH environment variable.
100 List<File> whichAll(String execName) => _which(execName, all: true);
101
102 /// Return the File representing a new pipe.
103 File makePipe(String path);
104
105 /// Return a directory's total size in bytes.
106 int? getDirectorySize(Directory directory) {
107 int? size;
108 for (final FileSystemEntity entity in directory.listSync(recursive: true, followLinks: false)) {
109 if (entity is File) {
110 size ??= 0;
111 size += entity.lengthSync();
112 }
113 }
114 return size;
115 }
116
117 void unzip(File file, Directory targetDirectory);
118
119 void unpack(File gzippedTarFile, Directory targetDirectory);
120
121 /// Compresses a stream using gzip level 1 (faster but larger).
122 Stream<List<int>> gzipLevel1Stream(Stream<List<int>> stream) {
123 return stream.cast<List<int>>().transform<List<int>>(gzipLevel1.encoder);
124 }
125
126 /// Returns a pretty name string for the current operating system.
127 ///
128 /// If available, the detailed version of the OS is included.
129 String get name {
130 const osNames = <String, String>{'macos': 'Mac OS', 'linux': 'Linux', 'windows': 'Windows'};
131 final String osName = _platform.operatingSystem;
132 return osNames[osName] ?? osName;
133 }
134
135 HostPlatform get hostPlatform;
136
137 List<File> _which(String execName, {bool all = false});
138
139 /// Returns the separator between items in the PATH environment variable.
140 String get pathVarSeparator;
141
142 /// Returns an unused network port.
143 ///
144 /// Returns 0 if an unused port cannot be found.
145 ///
146 /// The port returned by this function may become used before it is bound by
147 /// its intended user.
148 Future<int> findFreePort({bool ipv6 = false}) async {
149 var port = 0;
150 ServerSocket? serverSocket;
151 final InternetAddress loopback = ipv6
152 ? InternetAddress.loopbackIPv6
153 : InternetAddress.loopbackIPv4;
154 try {
155 serverSocket = await ServerSocket.bind(loopback, 0);
156 port = serverSocket.port;
157 } on SocketException catch (e) {
158 // If ipv4 loopback bind fails, try ipv6.
159 if (!ipv6) {
160 return findFreePort(ipv6: true);
161 }
162 _logger.printTrace('findFreePort failed: $e');
163 } on Exception catch (e) {
164 // Failures are signaled by a return value of 0 from this function.
165 _logger.printTrace('findFreePort failed: $e');
166 } finally {
167 if (serverSocket != null) {
168 await serverSocket.close();
169 }
170 }
171 return port;
172 }
173}
174
175class _PosixUtils extends OperatingSystemUtils {
176 _PosixUtils({
177 required super.fileSystem,
178 required super.logger,
179 required super.platform,
180 required super.processManager,
181 }) : super._private();
182
183 @override
184 void makeExecutable(File file) {
185 chmod(file, 'a+x');
186 }
187
188 @override
189 void chmod(FileSystemEntity entity, String mode) {
190 // Errors here are silently ignored (except when tracing).
191 try {
192 final ProcessResult result = _processManager.runSync(<String>['chmod', mode, entity.path]);
193 if (result.exitCode != 0) {
194 _logger.printTrace(
195 'Error trying to run "chmod $mode ${entity.path}":\n'
196 ' exit code: ${result.exitCode}\n'
197 ' stdout: ${result.stdout.toString().trimRight()}\n'
198 ' stderr: ${result.stderr.toString().trimRight()}',
199 );
200 }
201 } on ProcessException catch (error) {
202 _logger.printTrace('Error trying to run "chmod $mode ${entity.path}": $error');
203 }
204 }
205
206 @override
207 List<File> _which(String execName, {bool all = false}) {
208 final command = <String>['which', if (all) '-a', execName];
209 final ProcessResult result = _processManager.runSync(command);
210 if (result.exitCode != 0) {
211 return const <File>[];
212 }
213 final stdout = result.stdout as String;
214 return stdout
215 .trim()
216 .split('\n')
217 .map<File>((String path) => _fileSystem.file(path.trim()))
218 .toList();
219 }
220
221 // unzip -o -q zipfile -d dest
222 @override
223 void unzip(File file, Directory targetDirectory) {
224 if (!_processManager.canRun('unzip')) {
225 // unzip is not available. this error message is modeled after the download
226 // error in bin/internal/update_dart_sdk.sh
227 var message = 'Please install unzip.';
228 if (_platform.isMacOS) {
229 message = 'Consider running "brew install unzip".';
230 } else if (_platform.isLinux) {
231 message = 'Consider running "sudo apt-get install unzip".';
232 }
233 throwToolExit('Missing "unzip" tool. Unable to extract ${file.path}.\n$message');
234 }
235 _processUtils.runSync(
236 <String>['unzip', '-o', '-q', file.path, '-d', targetDirectory.path],
237 throwOnError: true,
238 verboseExceptions: true,
239 );
240 }
241
242 // tar -xzf tarball -C dest
243 @override
244 void unpack(File gzippedTarFile, Directory targetDirectory) {
245 _processUtils.runSync(<String>[
246 'tar',
247 '-xzf',
248 gzippedTarFile.path,
249 '-C',
250 targetDirectory.path,
251 ], throwOnError: true);
252 }
253
254 @override
255 File makePipe(String path) {
256 _processUtils.runSync(<String>['mkfifo', path], throwOnError: true);
257 return _fileSystem.file(path);
258 }
259
260 @override
261 String get pathVarSeparator => ':';
262
263 HostPlatform? _hostPlatform;
264
265 @override
266 HostPlatform get hostPlatform {
267 if (_hostPlatform == null) {
268 final RunResult hostPlatformCheck = _processUtils.runSync(<String>['uname', '-m']);
269 // On x64 stdout is "uname -m: x86_64"
270 // On arm64 stdout is "uname -m: aarch64, arm64_v8a"
271 if (hostPlatformCheck.exitCode != 0) {
272 _hostPlatform = HostPlatform.linux_x64;
273 _logger.printError(
274 'Encountered an error trying to run "uname -m":\n'
275 ' exit code: ${hostPlatformCheck.exitCode}\n'
276 ' stdout: ${hostPlatformCheck.stdout.trimRight()}\n'
277 ' stderr: ${hostPlatformCheck.stderr.trimRight()}\n'
278 'Assuming host platform is ${getNameForHostPlatform(_hostPlatform!)}.',
279 );
280 } else if (hostPlatformCheck.stdout.trim().endsWith('x86_64')) {
281 _hostPlatform = HostPlatform.linux_x64;
282 } else {
283 // We default to ARM if it's not x86_64 and we did not get an error.
284 _hostPlatform = HostPlatform.linux_arm64;
285 }
286 }
287 return _hostPlatform!;
288 }
289}
290
291class _LinuxUtils extends _PosixUtils {
292 _LinuxUtils({
293 required super.fileSystem,
294 required super.logger,
295 required super.platform,
296 required super.processManager,
297 });
298
299 String? _name;
300
301 @override
302 String get name {
303 if (_name == null) {
304 const prettyNameKey = 'PRETTY_NAME';
305 // If "/etc/os-release" doesn't exist, fallback to "/usr/lib/os-release".
306 final osReleasePath = _fileSystem.file('/etc/os-release').existsSync()
307 ? '/etc/os-release'
308 : '/usr/lib/os-release';
309 String prettyName;
310 String kernelRelease;
311 try {
312 final String osRelease = _fileSystem.file(osReleasePath).readAsStringSync();
313 prettyName = _getOsReleaseValueForKey(osRelease, prettyNameKey);
314 } on Exception catch (e) {
315 _logger.printTrace('Failed obtaining PRETTY_NAME for Linux: $e');
316 prettyName = '';
317 }
318 try {
319 // Split the operating system version which should be formatted as
320 // "Linux kernelRelease build", by spaces.
321 final List<String> osVersionSplit = _platform.operatingSystemVersion.split(' ');
322 if (osVersionSplit.length < 3) {
323 // The operating system version didn't have the expected format.
324 // Initialize as an empty string.
325 kernelRelease = '';
326 } else {
327 kernelRelease = ' ${osVersionSplit[1]}';
328 }
329 } on Exception catch (e) {
330 _logger.printTrace('Failed obtaining kernel release for Linux: $e');
331 kernelRelease = '';
332 }
333 _name = '${prettyName.isEmpty ? super.name : prettyName}$kernelRelease';
334 }
335 return _name!;
336 }
337
338 String _getOsReleaseValueForKey(String osRelease, String key) {
339 final List<String> osReleaseSplit = osRelease.split('\n');
340 for (var entry in osReleaseSplit) {
341 entry = entry.trim();
342 final List<String> entryKeyValuePair = entry.split('=');
343 if (entryKeyValuePair[0] == key) {
344 final String value = entryKeyValuePair[1];
345 // Remove quotes from either end of the value if they exist
346 final String quote = value[0];
347 if (quote == "'" || quote == '"') {
348 return value.substring(0, value.length - 1).substring(1);
349 } else {
350 return value;
351 }
352 }
353 }
354 return '';
355 }
356}
357
358class _MacOSUtils extends _PosixUtils {
359 _MacOSUtils({
360 required super.fileSystem,
361 required super.logger,
362 required super.platform,
363 required super.processManager,
364 });
365
366 String? _name;
367
368 @override
369 String get name {
370 if (_name == null) {
371 final results = <RunResult>[
372 _processUtils.runSync(<String>['sw_vers', '-productName']),
373 _processUtils.runSync(<String>['sw_vers', '-productVersion']),
374 _processUtils.runSync(<String>['sw_vers', '-buildVersion']),
375 _processUtils.runSync(<String>['uname', '-m']),
376 ];
377 if (results.every((RunResult result) => result.exitCode == 0)) {
378 String osName = getNameForHostPlatform(hostPlatform);
379 // If the script is running in Rosetta, "uname -m" will return x86_64.
380 if (hostPlatform == HostPlatform.darwin_arm64 && results[3].stdout.contains('x86_64')) {
381 osName = '$osName (Rosetta)';
382 }
383 _name =
384 '${results[0].stdout.trim()} ${results[1].stdout.trim()} ${results[2].stdout.trim()} $osName';
385 }
386 _name ??= super.name;
387 }
388 return _name!;
389 }
390
391 // On ARM returns arm64, even when this process is running in Rosetta.
392 @override
393 HostPlatform get hostPlatform {
394 if (_hostPlatform == null) {
395 String? sysctlPath;
396 if (which('sysctl') == null) {
397 // Fallback to known install locations.
398 for (final path in <String>['/usr/sbin/sysctl', '/sbin/sysctl']) {
399 if (_fileSystem.isFileSync(path)) {
400 sysctlPath = path;
401 }
402 }
403 } else {
404 sysctlPath = 'sysctl';
405 }
406
407 if (sysctlPath == null) {
408 throwToolExit('sysctl not found. Try adding it to your PATH environment variable.');
409 }
410 final RunResult arm64Check = _processUtils.runSync(<String>[sysctlPath, 'hw.optional.arm64']);
411 // On arm64 stdout is "sysctl hw.optional.arm64: 1"
412 // On x86 hw.optional.arm64 is unavailable and exits with 1.
413 if (arm64Check.exitCode == 0 && arm64Check.stdout.trim().endsWith('1')) {
414 _hostPlatform = HostPlatform.darwin_arm64;
415 } else {
416 _hostPlatform = HostPlatform.darwin_x64;
417 }
418 }
419 return _hostPlatform!;
420 }
421
422 // unzip, then rsync
423 @override
424 void unzip(File file, Directory targetDirectory) {
425 if (!_processManager.canRun('unzip')) {
426 // unzip is not available. this error message is modeled after the download
427 // error in bin/internal/update_dart_sdk.sh
428 throwToolExit(
429 'Missing "unzip" tool. Unable to extract ${file.path}.\nConsider running "brew install unzip".',
430 );
431 }
432 if (_processManager.canRun('rsync')) {
433 final Directory tempDirectory = _fileSystem.systemTempDirectory.createTempSync(
434 'flutter_${file.basename}.',
435 );
436 try {
437 // Unzip to a temporary directory.
438 _processUtils.runSync(
439 <String>['unzip', '-o', '-q', file.path, '-d', tempDirectory.path],
440 throwOnError: true,
441 verboseExceptions: true,
442 );
443 for (final FileSystemEntity unzippedFile in tempDirectory.listSync(followLinks: false)) {
444 // rsync --delete the unzipped files so files removed from the archive are also removed from the target.
445 // Add the '-8' parameter to avoid mangling filenames with encodings that do not match the current locale.
446 _processUtils.runSync(
447 <String>['rsync', '-8', '-av', '--delete', unzippedFile.path, targetDirectory.path],
448 throwOnError: true,
449 verboseExceptions: true,
450 );
451 }
452 } finally {
453 tempDirectory.deleteSync(recursive: true);
454 }
455 } else {
456 // Fall back to just unzipping.
457 _logger.printTrace('Unable to find rsync, falling back to direct unzipping.');
458 _processUtils.runSync(
459 <String>['unzip', '-o', '-q', file.path, '-d', targetDirectory.path],
460 throwOnError: true,
461 verboseExceptions: true,
462 );
463 }
464 }
465}
466
467class _WindowsUtils extends OperatingSystemUtils {
468 _WindowsUtils({
469 required super.fileSystem,
470 required super.logger,
471 required super.platform,
472 required super.processManager,
473 }) : super._private();
474
475 HostPlatform? _hostPlatform;
476
477 @override
478 HostPlatform get hostPlatform {
479 if (_hostPlatform == null) {
480 final abi = Abi.current();
481 _hostPlatform = (abi == Abi.windowsArm64)
482 ? HostPlatform.windows_arm64
483 : HostPlatform.windows_x64;
484 }
485 return _hostPlatform!;
486 }
487
488 @override
489 void makeExecutable(File file) {}
490
491 @override
492 void chmod(FileSystemEntity entity, String mode) {}
493
494 @override
495 List<File> _which(String execName, {bool all = false}) {
496 if (!_processManager.canRun('where')) {
497 // `where` could be missing if system32 is not on the PATH.
498 throwToolExit(
499 'Cannot find the executable for `where`. This can happen if the System32 '
500 r'folder (e.g. C:\Windows\System32 ) is removed from the PATH environment '
501 'variable. Ensure that this is present and then try again after restarting '
502 'the terminal and/or IDE.',
503 );
504 }
505 // `where` always returns all matches, not just the first one.
506 final ProcessResult result = _processManager.runSync(<String>['where', execName]);
507 if (result.exitCode != 0) {
508 return const <File>[];
509 }
510 final List<String> lines = (result.stdout as String).trim().split('\n');
511 if (all) {
512 return lines.map<File>((String path) => _fileSystem.file(path.trim())).toList();
513 }
514 return <File>[_fileSystem.file(lines.first.trim())];
515 }
516
517 @override
518 void unzip(File file, Directory targetDirectory) {
519 final Archive archive = ZipDecoder().decodeBytes(file.readAsBytesSync());
520 _unpackArchive(archive, targetDirectory);
521 }
522
523 @override
524 void unpack(File gzippedTarFile, Directory targetDirectory) {
525 final Archive archive = TarDecoder().decodeBytes(
526 GZipDecoder().decodeBytes(gzippedTarFile.readAsBytesSync()),
527 );
528 _unpackArchive(archive, targetDirectory);
529 }
530
531 void _unpackArchive(Archive archive, Directory targetDirectory) {
532 for (final ArchiveFile archiveFile in archive.files) {
533 // The archive package doesn't correctly set isFile.
534 if (!archiveFile.isFile || archiveFile.name.endsWith('/')) {
535 continue;
536 }
537
538 final File destFile = _fileSystem.file(
539 _fileSystem.path.canonicalize(
540 _fileSystem.path.join(targetDirectory.path, archiveFile.name),
541 ),
542 );
543
544 // Validate that the destFile is within the targetDirectory we want to
545 // extract to.
546 //
547 // See https://snyk.io/research/zip-slip-vulnerability for more context.
548 final String destinationFileCanonicalPath = _fileSystem.path.canonicalize(destFile.path);
549 final String targetDirectoryCanonicalPath = _fileSystem.path.canonicalize(
550 targetDirectory.path,
551 );
552 if (!destinationFileCanonicalPath.startsWith(targetDirectoryCanonicalPath)) {
553 throw StateError(
554 'Tried to extract the file $destinationFileCanonicalPath outside of the '
555 'target directory $targetDirectoryCanonicalPath',
556 );
557 }
558
559 if (!destFile.parent.existsSync()) {
560 destFile.parent.createSync(recursive: true);
561 }
562 destFile.writeAsBytesSync(archiveFile.content as List<int>);
563 }
564 }
565
566 @override
567 File makePipe(String path) {
568 throw UnsupportedError('makePipe is not implemented on Windows.');
569 }
570
571 String? _name;
572
573 @override
574 String get name {
575 if (_name == null) {
576 final ProcessResult result = _processManager.runSync(<String>['ver'], runInShell: true);
577 if (result.exitCode == 0) {
578 _name = (result.stdout as String).trim();
579 } else {
580 _name = super.name;
581 }
582 }
583 return _name!;
584 }
585
586 @override
587 String get pathVarSeparator => ';';
588}
589
590/// Find and return the project root directory relative to the specified
591/// directory or the current working directory if none specified.
592/// Return null if the project root could not be found
593/// or if the project root is the flutter repository root.
594String? findProjectRoot(FileSystem fileSystem, [String? directory]) {
595 const kProjectRootSentinel = 'pubspec.yaml';
596 directory ??= fileSystem.currentDirectory.path;
597 Directory currentDirectory = fileSystem.directory(directory).absolute;
598 while (true) {
599 if (currentDirectory.childFile(kProjectRootSentinel).existsSync()) {
600 return currentDirectory.path;
601 }
602 if (!currentDirectory.existsSync() || currentDirectory.parent.path == currentDirectory.path) {
603 return null;
604 }
605 currentDirectory = currentDirectory.parent;
606 }
607}
608
609enum HostPlatform {
610 darwin_x64,
611 darwin_arm64,
612 linux_x64,
613 linux_arm64,
614 windows_x64,
615 windows_arm64;
616
617 String get platformName => switch (this) {
618 HostPlatform.darwin_x64 => 'x64',
619 HostPlatform.darwin_arm64 => 'arm64',
620 HostPlatform.linux_x64 => 'x64',
621 HostPlatform.linux_arm64 => 'arm64',
622 HostPlatform.windows_x64 => 'x64',
623 HostPlatform.windows_arm64 => 'arm64',
624 };
625}
626
627String getNameForHostPlatform(HostPlatform platform) {
628 return switch (platform) {
629 HostPlatform.darwin_x64 => 'darwin-x64',
630 HostPlatform.darwin_arm64 => 'darwin-arm64',
631 HostPlatform.linux_x64 => 'linux-x64',
632 HostPlatform.linux_arm64 => 'linux-arm64',
633 HostPlatform.windows_x64 => 'windows-x64',
634 HostPlatform.windows_arm64 => 'windows-arm64',
635 };
636}
637