os.dart 19.7 KB
Newer Older
Ian Hickson's avatar
Ian Hickson committed
1
// Copyright 2014 The Flutter Authors. All rights reserved.
2 3 4
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

5
import 'package:archive/archive.dart';
6 7
import 'package:file/file.dart';
import 'package:meta/meta.dart';
8
import 'package:process/process.dart';
9

10
import 'common.dart';
11
import 'file_system.dart';
12
import 'io.dart';
13
import 'logger.dart';
14
import 'platform.dart';
15
import 'process.dart';
16

17
abstract class OperatingSystemUtils {
18
  factory OperatingSystemUtils({
19 20 21 22
    required FileSystem fileSystem,
    required Logger logger,
    required Platform platform,
    required ProcessManager processManager,
23 24 25 26 27 28 29 30
  }) {
    if (platform.isWindows) {
      return _WindowsUtils(
        fileSystem: fileSystem,
        logger: logger,
        platform: platform,
        processManager: processManager,
      );
31 32 33 34 35 36 37
    } else if (platform.isMacOS) {
      return _MacOSUtils(
        fileSystem: fileSystem,
        logger: logger,
        platform: platform,
        processManager: processManager,
      );
38 39 40 41 42 43 44
    } else if (platform.isLinux) {
      return _LinuxUtils(
        fileSystem: fileSystem,
        logger: logger,
        platform: platform,
        processManager: processManager,
      );
45
    } else {
46 47 48 49 50 51
      return _PosixUtils(
        fileSystem: fileSystem,
        logger: logger,
        platform: platform,
        processManager: processManager,
      );
52 53 54
    }
  }

55
  OperatingSystemUtils._private({
56 57 58 59
    required FileSystem fileSystem,
    required Logger logger,
    required Platform platform,
    required ProcessManager processManager,
60 61 62 63 64 65 66 67 68
  }) : _fileSystem = fileSystem,
       _logger = logger,
       _platform = platform,
       _processManager = processManager,
       _processUtils = ProcessUtils(
        logger: logger,
        processManager: processManager,
      );

69 70 71
  @visibleForTesting
  static final GZipCodec gzipLevel1 = GZipCodec(level: 1);

72 73 74 75 76
  final FileSystem _fileSystem;
  final Logger _logger;
  final Platform _platform;
  final ProcessManager _processManager;
  final ProcessUtils _processUtils;
77

78
  /// Make the given file executable. This may be a no-op on some platforms.
79 80 81 82 83 84 85 86 87
  void makeExecutable(File file);

  /// Updates the specified file system [entity] to have the file mode
  /// bits set to the value defined by [mode], which can be specified in octal
  /// (e.g. `644`) or symbolically (e.g. `u+x`).
  ///
  /// On operating systems that do not support file mode bits, this will be a
  /// no-op.
  void chmod(FileSystemEntity entity, String mode);
88

89
  /// Return the path (with symlinks resolved) to the given executable, or null
90
  /// if `which` was not able to locate the binary.
91
  File? which(String execName) {
92
    final List<File> result = _which(execName);
93
    if (result == null || result.isEmpty) {
94
      return null;
95
    }
96 97
    return result.first;
  }
98

99 100
  /// Return a list of all paths to `execName` found on the system. Uses the
  /// PATH environment variable.
101
  List<File> whichAll(String execName) => _which(execName, all: true);
102

103 104 105
  /// Return the File representing a new pipe.
  File makePipe(String path);

106
  void unzip(File file, Directory targetDirectory);
107

108 109
  void unpack(File gzippedTarFile, Directory targetDirectory);

110 111 112 113 114
  /// Compresses a stream using gzip level 1 (faster but larger).
  Stream<List<int>> gzipLevel1Stream(Stream<List<int>> stream) {
    return stream.cast<List<int>>().transform<List<int>>(gzipLevel1.encoder);
  }

115 116 117 118
  /// Returns a pretty name string for the current operating system.
  ///
  /// If available, the detailed version of the OS is included.
  String get name {
119
    const Map<String, String> osNames = <String, String>{
120 121
      'macos': 'Mac OS',
      'linux': 'Linux',
122
      'windows': 'Windows',
123
    };
124
    final String osName = _platform.operatingSystem;
125
    return osNames[osName] ?? osName;
126 127
  }

128 129
  HostPlatform get hostPlatform;

130
  List<File> _which(String execName, { bool all = false });
131 132 133

  /// Returns the separator between items in the PATH environment variable.
  String get pathVarSeparator;
134 135 136 137 138 139 140 141 142

  /// Returns an unused network port.
  ///
  /// Returns 0 if an unused port cannot be found.
  ///
  /// The port returned by this function may become used before it is bound by
  /// its intended user.
  Future<int> findFreePort({bool ipv6 = false}) async {
    int port = 0;
143
    ServerSocket? serverSocket;
144 145 146 147 148 149 150 151 152 153
    final InternetAddress loopback =
        ipv6 ? InternetAddress.loopbackIPv6 : InternetAddress.loopbackIPv4;
    try {
      serverSocket = await ServerSocket.bind(loopback, 0);
      port = serverSocket.port;
    } on SocketException catch (e) {
      // If ipv4 loopback bind fails, try ipv6.
      if (!ipv6) {
        return findFreePort(ipv6: true);
      }
154
      _logger.printTrace('findFreePort failed: $e');
155
    } on Exception catch (e) {
156
      // Failures are signaled by a return value of 0 from this function.
157
      _logger.printTrace('findFreePort failed: $e');
158 159 160 161 162 163 164
    } finally {
      if (serverSocket != null) {
        await serverSocket.close();
      }
    }
    return port;
  }
165 166
}

167
class _PosixUtils extends OperatingSystemUtils {
168
  _PosixUtils({
169 170 171 172 173
    required super.fileSystem,
    required super.logger,
    required super.platform,
    required super.processManager,
  }) : super._private();
174

175
  @override
176 177 178 179 180 181
  void makeExecutable(File file) {
    chmod(file, 'a+x');
  }

  @override
  void chmod(FileSystemEntity entity, String mode) {
182
    // Errors here are silently ignored (except when tracing).
183
    try {
184 185 186
      final ProcessResult result = _processManager.runSync(
        <String>['chmod', mode, entity.path],
      );
187
      if (result.exitCode != 0) {
188
        _logger.printTrace(
189 190 191 192
          'Error trying to run "chmod $mode ${entity.path}":\n'
          '  exit code: ${result.exitCode}\n'
          '  stdout: ${result.stdout.toString().trimRight()}\n'
          '  stderr: ${result.stderr.toString().trimRight()}'
193 194 195
        );
      }
    } on ProcessException catch (error) {
196
      _logger.printTrace(
197
        'Error trying to run "chmod $mode ${entity.path}": $error',
198
      );
199
    }
200
  }
201

202
  @override
203
  List<File> _which(String execName, { bool all = false }) {
204 205 206 207 208
    final List<String> 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 String stdout = result.stdout as String;
214 215 216
    return stdout.trim().split('\n').map<File>(
      (String path) => _fileSystem.file(path.trim()),
    ).toList();
217
  }
218 219 220 221

  // unzip -o -q zipfile -d dest
  @override
  void unzip(File file, Directory targetDirectory) {
222
    if (!_processManager.canRun('unzip')) {
223 224 225 226 227 228 229 230 231 232 233 234
      // unzip is not available. this error message is modeled after the download
      // error in bin/internal/update_dart_sdk.sh
      String message = 'Please install unzip.';
      if (_platform.isMacOS) {
        message = 'Consider running "brew install unzip".';
      } else if (_platform.isLinux) {
        message = 'Consider running "sudo apt-get install unzip".';
      }
      throwToolExit(
        'Missing "unzip" tool. Unable to extract ${file.path}.\n$message'
      );
    }
235 236 237 238 239
    _processUtils.runSync(
      <String>['unzip', '-o', '-q', file.path, '-d', targetDirectory.path],
      throwOnError: true,
      verboseExceptions: true,
    );
240
  }
241

242 243 244
  // tar -xzf tarball -C dest
  @override
  void unpack(File gzippedTarFile, Directory targetDirectory) {
245
    _processUtils.runSync(
246 247 248
      <String>['tar', '-xzf', gzippedTarFile.path, '-C', targetDirectory.path],
      throwOnError: true,
    );
249 250
  }

251 252
  @override
  File makePipe(String path) {
253
    _processUtils.runSync(
254 255 256
      <String>['mkfifo', path],
      throwOnError: true,
    );
257
    return _fileSystem.file(path);
258
  }
259

260 261 262
  @override
  String get pathVarSeparator => ':';

263
  HostPlatform? _hostPlatform;
264

265
  @override
266 267
  HostPlatform get hostPlatform {
    if (_hostPlatform == null) {
268
      final RunResult hostPlatformCheck = _processUtils.runSync(<String>['uname', '-m']);
269 270 271
      // On x64 stdout is "uname -m: x86_64"
      // On arm64 stdout is "uname -m: aarch64, arm64_v8a"
      if (hostPlatformCheck.exitCode != 0) {
272
        _hostPlatform = HostPlatform.linux_x64;
273
        _logger.printError(
274 275 276 277 278
          'Encountered an error trying to run "uname -m":\n'
          '  exit code: ${hostPlatformCheck.exitCode}\n'
          '  stdout: ${hostPlatformCheck.stdout.trimRight()}\n'
          '  stderr: ${hostPlatformCheck.stderr.trimRight()}\n'
          'Assuming host platform is ${getNameForHostPlatform(_hostPlatform!)}.',
279 280 281 282
        );
      } else if (hostPlatformCheck.stdout.trim().endsWith('x86_64')) {
        _hostPlatform = HostPlatform.linux_x64;
      } else {
283
        // We default to ARM if it's not x86_64 and we did not get an error.
284 285 286
        _hostPlatform = HostPlatform.linux_arm64;
      }
    }
287
    return _hostPlatform!;
288
  }
289 290
}

291 292
class _LinuxUtils extends _PosixUtils {
  _LinuxUtils({
293 294 295 296 297
    required super.fileSystem,
    required super.logger,
    required super.platform,
    required super.processManager,
  });
298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346

  String? _name;

  @override
  String get name {
    if (_name == null) {
      const String prettyNameKey = 'PRETTY_NAME';
      // If "/etc/os-release" doesn't exist, fallback to "/usr/lib/os-release".
      final String osReleasePath = _fileSystem.file('/etc/os-release').existsSync()
        ? '/etc/os-release'
        : '/usr/lib/os-release';
      String prettyName;
      String kernelRelease;
      try {
        final String osRelease = _fileSystem.file(osReleasePath).readAsStringSync();
        prettyName = _getOsReleaseValueForKey(osRelease, prettyNameKey);
      } on Exception catch (e) {
        _logger.printTrace('Failed obtaining PRETTY_NAME for Linux: $e');
        prettyName = '';
      }
      try {
        // Split the operating system version which should be formatted as
        // "Linux kernelRelease build", by spaces.
        final List<String> osVersionSplitted = _platform.operatingSystemVersion.split(' ');
        if (osVersionSplitted.length < 3) {
          // The operating system version didn't have the expected format.
          // Initialize as an empty string.
          kernelRelease = '';
        } else {
          kernelRelease = ' ${osVersionSplitted[1]}';
        }
      } on Exception catch (e) {
        _logger.printTrace('Failed obtaining kernel release for Linux: $e');
        kernelRelease = '';
      }
      _name = '${prettyName.isEmpty ? super.name : prettyName}$kernelRelease';
    }
    return _name!;
  }

  String _getOsReleaseValueForKey(String osRelease, String key) {
    final List<String> osReleaseSplitted = osRelease.split('\n');
    for (String entry in osReleaseSplitted) {
      entry = entry.trim();
      final List<String> entryKeyValuePair = entry.split('=');
      if(entryKeyValuePair[0] == key) {
        final String value =  entryKeyValuePair[1];
        // Remove quotes from either end of the value if they exist
        final String quote = value[0];
347
        if (quote == "'" || quote == '"') {
348 349 350 351 352 353 354 355 356 357
          return value.substring(0, value.length - 1).substring(1);
        } else {
          return value;
        }
      }
    }
    return '';
  }
}

358 359
class _MacOSUtils extends _PosixUtils {
  _MacOSUtils({
360 361 362 363 364
    required super.fileSystem,
    required super.logger,
    required super.platform,
    required super.processManager,
  });
365

366
  String? _name;
367 368 369

  @override
  String get name {
370
    if (_name == null) {
371 372 373 374
      final List<RunResult> results = <RunResult>[
        _processUtils.runSync(<String>['sw_vers', '-productName']),
        _processUtils.runSync(<String>['sw_vers', '-productVersion']),
        _processUtils.runSync(<String>['sw_vers', '-buildVersion']),
375
        _processUtils.runSync(<String>['uname', '-m']),
376 377
      ];
      if (results.every((RunResult result) => result.exitCode == 0)) {
378 379 380 381 382
        String osName = getNameForHostPlatform(hostPlatform);
        // If the script is running in Rosetta, "uname -m" will return x86_64.
        if (hostPlatform == HostPlatform.darwin_arm && results[3].stdout.contains('x86_64')) {
          osName = '$osName (Rosetta)';
        }
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 394
  HostPlatform get hostPlatform {
    if (_hostPlatform == null) {
395
      String? sysctlPath;
396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412
      if (which('sysctl') == null) {
        // Fallback to known install locations.
        for (final String path in <String>[
          '/usr/sbin/sysctl',
          '/sbin/sysctl',
        ]) {
          if (_fileSystem.isFileSync(path)) {
            sysctlPath = path;
          }
        }
      } else {
        sysctlPath = 'sysctl';
      }

      if (sysctlPath == null) {
        throwToolExit('sysctl not found. Try adding it to your PATH environment variable.');
      }
413
      final RunResult arm64Check =
414
          _processUtils.runSync(<String>[sysctlPath, 'hw.optional.arm64']);
415
      // On arm64 stdout is "sysctl hw.optional.arm64: 1"
416
      // On x86 hw.optional.arm64 is unavailable and exits with 1.
417 418 419 420 421 422
      if (arm64Check.exitCode == 0 && arm64Check.stdout.trim().endsWith('1')) {
        _hostPlatform = HostPlatform.darwin_arm;
      } else {
        _hostPlatform = HostPlatform.darwin_x64;
      }
    }
423
    return _hostPlatform!;
424
  }
425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444

  // unzip, then rsync
  @override
  void unzip(File file, Directory targetDirectory) {
    if (!_processManager.canRun('unzip')) {
      // unzip is not available. this error message is modeled after the download
      // error in bin/internal/update_dart_sdk.sh
      throwToolExit('Missing "unzip" tool. Unable to extract ${file.path}.\nConsider running "brew install unzip".');
    }
    if (_processManager.canRun('rsync')) {
      final Directory tempDirectory = _fileSystem.systemTempDirectory.createTempSync('flutter_${file.basename}.');
      try {
        // Unzip to a temporary directory.
        _processUtils.runSync(
          <String>['unzip', '-o', '-q', file.path, '-d', tempDirectory.path],
          throwOnError: true,
          verboseExceptions: true,
        );
        for (final FileSystemEntity unzippedFile in tempDirectory.listSync(followLinks: false)) {
          // 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 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464
            throwOnError: true,
            verboseExceptions: true,
          );
        }
      } finally {
        tempDirectory.deleteSync(recursive: true);
      }
    } else {
      // Fall back to just unzipping.
      _logger.printTrace('Unable to find rsync, falling back to direct unzipping.');
      _processUtils.runSync(
        <String>['unzip', '-o', '-q', file.path, '-d', targetDirectory.path],
        throwOnError: true,
        verboseExceptions: true,
      );
    }
  }
465 466
}

467
class _WindowsUtils extends OperatingSystemUtils {
468
  _WindowsUtils({
469 470 471 472 473
    required super.fileSystem,
    required super.logger,
    required super.platform,
    required super.processManager,
  }) : super._private();
474

475 476 477
  @override
  HostPlatform hostPlatform = HostPlatform.windows_x64;

478
  @override
479 480 481 482
  void makeExecutable(File file) {}

  @override
  void chmod(FileSystemEntity entity, String mode) {}
483

484
  @override
485
  List<File> _which(String execName, { bool all = false }) {
486
    if (!_processManager.canRun('where')) {
487 488 489
      // `where` could be missing if system32 is not on the PATH.
      throwToolExit(
        'Cannot find the executable for `where`. This can happen if the System32 '
490
        r'folder (e.g. C:\Windows\System32 ) is removed from the PATH environment '
491 492 493 494
        'variable. Ensure that this is present and then try again after restarting '
        'the terminal and/or IDE.'
      );
    }
495 496
    // `where` always returns all matches, not just the first one.
    final ProcessResult result = _processManager.runSync(<String>['where', execName]);
497
    if (result.exitCode != 0) {
498
      return const <File>[];
499
    }
500
    final List<String> lines = (result.stdout as String).trim().split('\n');
501
    if (all) {
502
      return lines.map<File>((String path) => _fileSystem.file(path.trim())).toList();
503
    }
504
    return <File>[_fileSystem.file(lines.first.trim())];
505 506 507 508
  }

  @override
  void unzip(File file, Directory targetDirectory) {
509 510
    final Archive archive = ZipDecoder().decodeBytes(file.readAsBytesSync());
    _unpackArchive(archive, targetDirectory);
511 512 513 514
  }

  @override
  void unpack(File gzippedTarFile, Directory targetDirectory) {
515 516
    final Archive archive = TarDecoder().decodeBytes(
      GZipDecoder().decodeBytes(gzippedTarFile.readAsBytesSync()),
517 518 519
    );
    _unpackArchive(archive, targetDirectory);
  }
520

521
  void _unpackArchive(Archive archive, Directory targetDirectory) {
522
    for (final ArchiveFile archiveFile in archive.files) {
523
      // The archive package doesn't correctly set isFile.
524
      if (!archiveFile.isFile || archiveFile.name.endsWith('/')) {
525
        continue;
526
      }
527

528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544
      final File destFile = _fileSystem.file(
        _fileSystem.path.canonicalize(
          _fileSystem.path.join(
            targetDirectory.path,
            archiveFile.name,
          ),
        ),
      );

      // Validate that the destFile is within the targetDirectory we want to
      // extract to.
      //
      // See https://snyk.io/research/zip-slip-vulnerability for more context.
      final String destinationFileCanonicalPath = _fileSystem.path.canonicalize(
        destFile.path,
      );
      final String targetDirectoryCanonicalPath = _fileSystem.path.canonicalize(
545
        targetDirectory.path,
546 547 548 549 550 551 552 553
      );
      if (!destinationFileCanonicalPath.startsWith(targetDirectoryCanonicalPath)) {
        throw StateError(
          'Tried to extract the file $destinationFileCanonicalPath outside of the '
          'target directory $targetDirectoryCanonicalPath',
        );
      }

554
      if (!destFile.parent.existsSync()) {
555
        destFile.parent.createSync(recursive: true);
556
      }
557
      destFile.writeAsBytesSync(archiveFile.content as List<int>);
558
    }
559
  }
560 561 562

  @override
  File makePipe(String path) {
563
    throw UnsupportedError('makePipe is not implemented on Windows.');
564
  }
565

566
  String? _name;
567 568 569 570

  @override
  String get name {
    if (_name == null) {
571
      final ProcessResult result = _processManager.runSync(
572
          <String>['ver'], runInShell: true);
573
      if (result.exitCode == 0) {
574
        _name = (result.stdout as String).trim();
575
      } else {
576
        _name = super.name;
577
      }
578
    }
579
    return _name!;
580
  }
581 582 583

  @override
  String get pathVarSeparator => ';';
584
}
585

586 587
/// Find and return the project root directory relative to the specified
/// directory or the current working directory if none specified.
588
/// Return null if the project root could not be found
589
/// or if the project root is the flutter repository root.
590
String? findProjectRoot(FileSystem fileSystem, [ String? directory ]) {
591
  const String kProjectRootSentinel = 'pubspec.yaml';
592
  directory ??= fileSystem.currentDirectory.path;
593
  while (true) {
594
    if (fileSystem.isFileSync(fileSystem.path.join(directory!, kProjectRootSentinel))) {
595
      return directory;
596
    }
597
    final String parent = fileSystem.path.dirname(directory);
598
    if (directory == parent) {
599
      return null;
600
    }
601 602 603
    directory = parent;
  }
}
604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626

enum HostPlatform {
  darwin_x64,
  darwin_arm,
  linux_x64,
  linux_arm64,
  windows_x64,
}

String getNameForHostPlatform(HostPlatform platform) {
  switch (platform) {
    case HostPlatform.darwin_x64:
      return 'darwin-x64';
    case HostPlatform.darwin_arm:
      return 'darwin-arm';
    case HostPlatform.linux_x64:
      return 'linux-x64';
    case HostPlatform.linux_arm64:
      return 'linux-arm64';
    case HostPlatform.windows_x64:
      return 'windows-x64';
  }
}