Hide PII from doctor validators for GitHub template (#96250)

diff --git a/packages/flutter_tools/lib/runner.dart b/packages/flutter_tools/lib/runner.dart
index b6bde82..c32ff13 100644
--- a/packages/flutter_tools/lib/runner.dart
+++ b/packages/flutter_tools/lib/runner.dart
@@ -149,11 +149,18 @@
     globals.printError('Oops; flutter has exited unexpectedly: "$error".');
 
     try {
+      final BufferLogger logger = BufferLogger(
+        terminal: globals.terminal,
+        outputPreferences: globals.outputPreferences,
+      );
+
+      final DoctorText doctorText = DoctorText(logger);
+
       final CrashDetails details = CrashDetails(
         command: _crashCommand(args),
         error: error,
         stackTrace: stackTrace,
-        doctorText: await _doctorText(),
+        doctorText: doctorText,
       );
       final File file = await _createLocalCrashReport(details);
       await globals.crashReporter.informUser(details, file);
@@ -199,7 +206,7 @@
   buffer.writeln('```\n${details.stackTrace}```\n');
 
   buffer.writeln('## flutter doctor\n');
-  buffer.writeln('```\n${details.doctorText}```');
+  buffer.writeln('```\n${await details.doctorText.text}```');
 
   try {
     crashFile.writeAsStringSync(buffer.toString());
@@ -221,22 +228,6 @@
   return crashFile;
 }
 
-Future<String> _doctorText() async {
-  try {
-    final BufferLogger logger = BufferLogger(
-      terminal: globals.terminal,
-      outputPreferences: globals.outputPreferences,
-    );
-
-    final Doctor doctor = Doctor(logger: logger);
-    await doctor.diagnose(showColor: false);
-
-    return logger.statusText;
-  } on Exception catch (error, trace) {
-    return 'encountered exception: $error\n\n${trace.toString().trim()}\n';
-  }
-}
-
 Future<int> _exit(int code) async {
   // Prints the welcome message if needed.
   globals.flutterUsage.printWelcome();
diff --git a/packages/flutter_tools/lib/src/doctor.dart b/packages/flutter_tools/lib/src/doctor.dart
index 598c338..d491c4d 100644
--- a/packages/flutter_tools/lib/src/doctor.dart
+++ b/packages/flutter_tools/lib/src/doctor.dart
@@ -2,6 +2,7 @@
 // Use of this source code is governed by a BSD-style license that can be
 // found in the LICENSE file.
 
+import 'package:meta/meta.dart';
 import 'package:process/process.dart';
 
 import 'android/android_studio_validator.dart';
@@ -290,11 +291,17 @@
   }
 
   /// Print information about the state of installed tooling.
+  ///
+  /// To exclude personally identifiable information like device names and
+  /// paths, set [showPii] to false.
   Future<bool> diagnose({
     bool androidLicenses = false,
     bool verbose = true,
     bool showColor = true,
     AndroidLicenseValidator? androidLicenseValidator,
+    bool showPii = true,
+    List<ValidatorTask>? startedValidatorTasks,
+    bool sendEvent = true,
   }) async {
     if (androidLicenses && androidLicenseValidator != null) {
       return androidLicenseValidator.runLicenseManager();
@@ -306,7 +313,7 @@
     bool doctorResult = true;
     int issues = 0;
 
-    for (final ValidatorTask validatorTask in startValidatorTasks()) {
+    for (final ValidatorTask validatorTask in startedValidatorTasks ?? startValidatorTasks()) {
       final DoctorValidator validator = validatorTask.validator;
       final Status status = _logger.startSpinner();
       ValidationResult result;
@@ -334,8 +341,9 @@
         case ValidationType.installed:
           break;
       }
-
-      DoctorResultEvent(validator: validator, result: result).send();
+      if (sendEvent) {
+        DoctorResultEvent(validator: validator, result: result).send();
+      }
 
       final String leadingBox = showColor ? result.coloredLeadingBox : result.leadingBox;
       if (result.statusInfo != null) {
@@ -351,7 +359,7 @@
           int hangingIndent = 2;
           int indent = 4;
           final String indicator = showColor ? message.coloredIndicator : message.indicator;
-          for (final String line in '$indicator ${message.message}'.split('\n')) {
+          for (final String line in '$indicator ${showPii ? message.message : message.piiStrippedMessage}'.split('\n')) {
             _logger.printStatus(line, hangingIndent: hangingIndent, indent: indent, emphasis: true);
             // Only do hanging indent for the first line.
             hangingIndent = 0;
@@ -558,3 +566,34 @@
     }
   }
 }
+
+/// Wrapper for doctor to run multiple times with PII and without, running the validators only once.
+class DoctorText {
+  DoctorText(
+    BufferLogger logger, {
+    @visibleForTesting Doctor? doctor,
+  }) : _doctor = doctor ?? Doctor(logger: logger), _logger = logger;
+
+  final BufferLogger _logger;
+  final Doctor _doctor;
+  bool _sendDoctorEvent = true;
+
+  late final Future<String> text = _runDiagnosis(true);
+  late final Future<String> piiStrippedText = _runDiagnosis(false);
+
+  // Start the validator tasks only once.
+  late final List<ValidatorTask> _validatorTasks = _doctor.startValidatorTasks();
+
+  Future<String> _runDiagnosis(bool showPii) async {
+    try {
+      await _doctor.diagnose(showColor: false, startedValidatorTasks: _validatorTasks, showPii: showPii, sendEvent: _sendDoctorEvent);
+      // Do not send the doctor event a second time.
+      _sendDoctorEvent = false;
+      final String text = _logger.statusText;
+      _logger.clear();
+      return text;
+    } on Exception catch (error, trace) {
+      return 'encountered exception: $error\n\n${trace.toString().trim()}\n';
+    }
+  }
+}
diff --git a/packages/flutter_tools/lib/src/doctor_validator.dart b/packages/flutter_tools/lib/src/doctor_validator.dart
index 8458855..6530b5f 100644
--- a/packages/flutter_tools/lib/src/doctor_validator.dart
+++ b/packages/flutter_tools/lib/src/doctor_validator.dart
@@ -221,22 +221,27 @@
   ///
   /// The [contextUrl] may be supplied to link to external resources. This
   /// is displayed after the informative message in verbose modes.
-  const ValidationMessage(this.message, {this.contextUrl}) : type = ValidationMessageType.information;
+  const ValidationMessage(this.message, { this.contextUrl, String? piiStrippedMessage })
+      : type = ValidationMessageType.information, piiStrippedMessage = piiStrippedMessage ?? message;
 
   /// Create a validation message with information for a failing validator.
-  const ValidationMessage.error(this.message)
+  const ValidationMessage.error(this.message, { String? piiStrippedMessage })
     : type = ValidationMessageType.error,
+      piiStrippedMessage = piiStrippedMessage ?? message,
       contextUrl = null;
 
   /// Create a validation message with information for a partially failing
   /// validator.
-  const ValidationMessage.hint(this.message)
+  const ValidationMessage.hint(this.message, { String? piiStrippedMessage })
     : type = ValidationMessageType.hint,
+      piiStrippedMessage = piiStrippedMessage ?? message,
       contextUrl = null;
 
   final ValidationMessageType type;
   final String? contextUrl;
   final String message;
+  /// Optional message with PII stripped, to show instead of [message].
+  final String piiStrippedMessage;
 
   bool get isError => type == ValidationMessageType.error;
 
diff --git a/packages/flutter_tools/lib/src/reporting/crash_reporting.dart b/packages/flutter_tools/lib/src/reporting/crash_reporting.dart
index d6f4f94..da9b4cd 100644
--- a/packages/flutter_tools/lib/src/reporting/crash_reporting.dart
+++ b/packages/flutter_tools/lib/src/reporting/crash_reporting.dart
@@ -12,6 +12,7 @@
 import '../base/logger.dart';
 import '../base/os.dart';
 import '../base/platform.dart';
+import '../doctor.dart';
 import '../project.dart';
 import 'github_template.dart';
 import 'reporting.dart';
@@ -49,7 +50,7 @@
   final String command;
   final Object error;
   final StackTrace stackTrace;
-  final String doctorText;
+  final DoctorText doctorText;
 }
 
 /// Reports information about the crash to the user.
@@ -92,7 +93,7 @@
       details.command,
       details.error,
       details.stackTrace,
-      details.doctorText,
+      await details.doctorText.piiStrippedText,
     );
     _logger.printStatus('$gitHubTemplateURL\n', wrap: false);
   }
diff --git a/packages/flutter_tools/test/commands.shard/hermetic/doctor_test.dart b/packages/flutter_tools/test/commands.shard/hermetic/doctor_test.dart
index 7a535de..e99711d 100644
--- a/packages/flutter_tools/test/commands.shard/hermetic/doctor_test.dart
+++ b/packages/flutter_tools/test/commands.shard/hermetic/doctor_test.dart
@@ -313,6 +313,14 @@
     }, overrides: <Type, Generator>{
       Usage: () => testUsage,
     });
+
+    testUsingContext('sending events can be skipped', () async {
+      await FakePassingDoctor(logger).diagnose(verbose: false, sendEvent: false);
+
+      expect(testUsage.events, isEmpty);
+    }, overrides: <Type, Generator>{
+      Usage: () => testUsage,
+    });
   });
 
   group('doctor with fake validators', () {
@@ -456,6 +464,78 @@
               '! Doctor found issues in 4 categories.\n'
       ));
     });
+
+    testUsingContext('validate PII can be hidden', () async {
+      expect(await FakePiiDoctor(logger).diagnose(showPii: false), isTrue);
+      expect(logger.statusText, equals(
+        '[✓] PII Validator\n'
+        '    • Does not contain PII\n'
+        '\n'
+        '• No issues found!\n'
+      ));
+      logger.clear();
+      // PII shown.
+      expect(await FakePiiDoctor(logger).diagnose(), isTrue);
+      expect(logger.statusText, equals(
+          '[✓] PII Validator\n'
+              '    • Contains PII path/to/username\n'
+              '\n'
+              '• No issues found!\n'
+      ));
+    });
+  });
+
+  group('doctor diagnosis wrapper', () {
+    TestUsage testUsage;
+    BufferLogger logger;
+
+    setUp(() {
+      testUsage = TestUsage();
+      logger = BufferLogger.test();
+    });
+
+    testUsingContext('PII separated, events only sent once', () async {
+      final Doctor fakeDoctor = FakePiiDoctor(logger);
+      final DoctorText doctorText = DoctorText(logger, doctor: fakeDoctor);
+      const String expectedPiiText = '[✓] PII Validator\n'
+          '    • Contains PII path/to/username\n'
+          '\n'
+          '• No issues found!\n';
+      const String expectedPiiStrippedText =
+          '[✓] PII Validator\n'
+          '    • Does not contain PII\n'
+          '\n'
+          '• No issues found!\n';
+
+      // Run each multiple times to make sure the logger buffer is being cleared,
+      // and that events are only sent once.
+      expect(await doctorText.text, expectedPiiText);
+      expect(await doctorText.text, expectedPiiText);
+
+      expect(await doctorText.piiStrippedText, expectedPiiStrippedText);
+      expect(await doctorText.piiStrippedText, expectedPiiStrippedText);
+
+      // Only one event sent.
+      expect(testUsage.events, <TestUsageEvent>[
+        const TestUsageEvent(
+          'doctor-result',
+          'PiiValidator',
+          label: 'installed',
+        ),
+      ]);
+    }, overrides: <Type, Generator>{
+      Usage: () => testUsage,
+    });
+
+    testUsingContext('without PII has same text and PII-stripped text', () async {
+      final Doctor fakeDoctor = FakePassingDoctor(logger);
+      final DoctorText doctorText = DoctorText(logger, doctor: fakeDoctor);
+      final String piiText = await doctorText.text;
+      expect(piiText, isNotEmpty);
+      expect(piiText, await doctorText.piiStrippedText);
+    }, overrides: <Type, Generator>{
+      Usage: () => testUsage,
+    });
   });
 
   testUsingContext('validate non-verbose output wrapping', () async {
@@ -535,7 +615,6 @@
     ));
   });
 
-
   group('doctor with grouped validators', () {
     testUsingContext('validate diagnose combines validator output', () async {
       expect(await FakeGroupedDoctor(logger).diagnose(), isTrue);
@@ -666,6 +745,9 @@
     bool verbose = true,
     bool showColor = true,
     AndroidLicenseValidator androidLicenseValidator,
+    bool showPii = true,
+    List<ValidatorTask> startedValidatorTasks,
+    bool sendEvent = true,
   }) async => true;
 
   @override
@@ -694,6 +776,18 @@
   }
 }
 
+class PiiValidator extends DoctorValidator {
+  PiiValidator() : super('PII Validator');
+
+  @override
+  Future<ValidationResult> validate() async {
+    const List<ValidationMessage> messages = <ValidationMessage>[
+      ValidationMessage('Contains PII path/to/username', piiStrippedMessage: 'Does not contain PII'),
+    ];
+    return const ValidationResult(ValidationType.installed, messages);
+  }
+}
+
 class MissingValidator extends DoctorValidator {
   MissingValidator() : super('Missing Validator');
 
@@ -840,6 +934,19 @@
   }
 }
 
+/// A doctor that passes and contains PII that can be hidden.
+class FakePiiDoctor extends Doctor {
+  FakePiiDoctor(Logger logger) : super(logger: logger);
+
+  List<DoctorValidator> _validators;
+  @override
+  List<DoctorValidator> get validators {
+    return _validators ??= <DoctorValidator>[
+      PiiValidator(),
+    ];
+  }
+}
+
 /// A doctor with a validator that throws an exception.
 class FakeCrashingDoctor extends Doctor {
   FakeCrashingDoctor(Logger logger) : super(logger: logger);
diff --git a/packages/flutter_tools/test/general.shard/analytics_test.dart b/packages/flutter_tools/test/general.shard/analytics_test.dart
index 2371d30..75266cd 100644
--- a/packages/flutter_tools/test/general.shard/analytics_test.dart
+++ b/packages/flutter_tools/test/general.shard/analytics_test.dart
@@ -17,6 +17,7 @@
 import 'package:flutter_tools/src/commands/config.dart';
 import 'package:flutter_tools/src/commands/doctor.dart';
 import 'package:flutter_tools/src/doctor.dart';
+import 'package:flutter_tools/src/doctor_validator.dart';
 import 'package:flutter_tools/src/features.dart';
 import 'package:flutter_tools/src/globals.dart' as globals;
 import 'package:flutter_tools/src/reporting/reporting.dart';
@@ -368,6 +369,9 @@
     bool verbose = true,
     bool showColor = true,
     AndroidLicenseValidator androidLicenseValidator,
+    bool showPii = true,
+    List<ValidatorTask> startedValidatorTasks,
+    bool sendEvent = true,
   }) async {
     return diagnoseSucceeds;
   }
diff --git a/packages/flutter_tools/test/general.shard/crash_reporting_test.dart b/packages/flutter_tools/test/general.shard/crash_reporting_test.dart
index d0c893b..93f596f 100644
--- a/packages/flutter_tools/test/general.shard/crash_reporting_test.dart
+++ b/packages/flutter_tools/test/general.shard/crash_reporting_test.dart
@@ -10,11 +10,13 @@
 import 'package:flutter_tools/src/base/logger.dart';
 import 'package:flutter_tools/src/base/os.dart';
 import 'package:flutter_tools/src/base/platform.dart';
+import 'package:flutter_tools/src/doctor.dart';
 import 'package:flutter_tools/src/project.dart';
 import 'package:flutter_tools/src/reporting/crash_reporting.dart';
 import 'package:flutter_tools/src/reporting/reporting.dart';
 import 'package:http/http.dart';
 import 'package:http/testing.dart';
+import 'package:test/fake.dart';
 
 import '../src/common.dart';
 import '../src/fake_http_client.dart';
@@ -76,7 +78,7 @@
     expect(logger.traceText, contains('Crash report sent (report ID: test-report-id)'));
   }
 
-  testWithoutContext('CrashReporter.informUser provides basic instructions', () async {
+  testWithoutContext('CrashReporter.informUser provides basic instructions without PII', () async {
     final CrashReporter crashReporter = CrashReporter(
       fileSystem: fs,
       logger: logger,
@@ -91,12 +93,16 @@
         command: 'arg1 arg2 arg3',
         error: Exception('Dummy exception'),
         stackTrace: StackTrace.current,
-        doctorText: 'Fake doctor text'),
+        // Spaces are URL query encoded in the output, make it one word to make this test simpler.
+        doctorText: FakeDoctorText('Ignored', 'NoPIIFakeDoctorText'),
+      ),
       file,
     );
 
-    expect(logger.errorText, contains('A crash report has been written to ${file.path}.'));
+    expect(logger.statusText, contains('NoPIIFakeDoctorText'));
+    expect(logger.statusText, isNot(contains('Ignored')));
     expect(logger.statusText, contains('https://github.com/flutter/flutter/issues/new'));
+    expect(logger.errorText, contains('A crash report has been written to ${file.path}.'));
   });
 
   testWithoutContext('suppress analytics', () async {
@@ -379,3 +385,16 @@
     throw exception;
   });
 }
+
+class FakeDoctorText extends Fake implements DoctorText {
+  FakeDoctorText(String text, String piiStrippedText)
+      : _text = text, _piiStrippedText = piiStrippedText;
+
+  @override
+  Future<String> get text async => _text;
+  final String _text;
+
+  @override
+  Future<String> get piiStrippedText async => _piiStrippedText;
+  final String _piiStrippedText;
+}
diff --git a/packages/flutter_tools/test/general.shard/github_template_test.dart b/packages/flutter_tools/test/general.shard/github_template_test.dart
index 279eb9c..81cbd81 100644
--- a/packages/flutter_tools/test/general.shard/github_template_test.dart
+++ b/packages/flutter_tools/test/general.shard/github_template_test.dart
@@ -308,4 +308,7 @@
   StackTrace get stackTrace => StackTrace.fromString('''
 #0      _File.open.<anonymous closure> (dart:io/file_impl.dart:366:9)
 #1      _rootRunUnary (dart:async/zone.dart:1141:38)''');
+
+  @override
+  String toString() => 'PII to ignore';
 }
diff --git a/packages/flutter_tools/test/general.shard/runner/runner_test.dart b/packages/flutter_tools/test/general.shard/runner/runner_test.dart
index 4001616..ef5e120 100644
--- a/packages/flutter_tools/test/general.shard/runner/runner_test.dart
+++ b/packages/flutter_tools/test/general.shard/runner/runner_test.dart
@@ -198,7 +198,7 @@
       expect(sentDetails.command, 'flutter crash');
       expect(sentDetails.error, 'an exception % --');
       expect(sentDetails.stackTrace.toString(), contains('CrashingFlutterCommand.runCommand'));
-      expect(sentDetails.doctorText, contains('[✓] Flutter'));
+      expect(await sentDetails.doctorText.text, contains('[✓] Flutter'));
     }, overrides: <Type, Generator>{
       Platform: () => FakePlatform(
         environment: <String, String>{