Skip to content

Commit 6139ff2

Browse files
refactor(core): implement value semantics and optimize string analysis
- Implement value semantics using `collection` dependency for core models - Consolidate character set validation from `ConfigAwarePasswordValidator` into `PolicyValidationMixin` - Optimize string allocations in `containsAnyOf` by using `String.contains` directly - Fix `satisfiesPolicy` to evaluate base config even when policy is null - Add fallback pool size for passwords consisting entirely of unmapped characters - Align generator interfaces and update related tests
1 parent 6d9a7f6 commit 6139ff2

16 files changed

Lines changed: 233 additions & 67 deletions

analysis_options.yaml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ include: package:lints/recommended.yaml
33
analyzer:
44
exclude:
55
- "lib/l10n/*.i18n.dart"
6-
-
76
linter:
87
rules:
98
prefer_single_quotes: true

lib/src/config/password_generator_config.dart

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import 'package:collection/collection.dart';
2+
13
import '../model/character_set_profile.dart';
24
import '../model/password_policy.dart';
35
import 'password_generator_config_builder.dart';
@@ -42,7 +44,10 @@ class PasswordGeneratorConfig {
4244
map['policy'] != null
4345
? PasswordPolicy.fromMap(map['policy'] as Map<String, dynamic>)
4446
: null,
45-
extra: (map['extra'] as Map<String, dynamic>?) ?? const {},
47+
extra:
48+
map['extra'] != null
49+
? Map.unmodifiable(map['extra'] as Map<String, dynamic>)
50+
: const {},
4651
);
4752
}
4853

@@ -83,6 +88,35 @@ class PasswordGeneratorConfig {
8388
/// Additional configuration parameters for custom strategies.
8489
final Map<String, dynamic> extra;
8590

91+
/// Returns a copy of this configuration with the given fields replaced.
92+
PasswordGeneratorConfig copyWith({
93+
int? length,
94+
bool? useUpperCase,
95+
bool? useLowerCase,
96+
bool? useNumbers,
97+
bool? useSpecialChars,
98+
bool? excludeAmbiguousChars,
99+
CharacterSetProfile? characterSetProfile,
100+
int? maxGenerationAttempts,
101+
Object? policy = _sentinel,
102+
Map<String, dynamic>? extra,
103+
}) {
104+
return PasswordGeneratorConfig(
105+
length: length ?? this.length,
106+
useUpperCase: useUpperCase ?? this.useUpperCase,
107+
useLowerCase: useLowerCase ?? this.useLowerCase,
108+
useNumbers: useNumbers ?? this.useNumbers,
109+
useSpecialChars: useSpecialChars ?? this.useSpecialChars,
110+
excludeAmbiguousChars:
111+
excludeAmbiguousChars ?? this.excludeAmbiguousChars,
112+
characterSetProfile: characterSetProfile ?? this.characterSetProfile,
113+
maxGenerationAttempts:
114+
maxGenerationAttempts ?? this.maxGenerationAttempts,
115+
policy: policy == _sentinel ? this.policy : policy as PasswordPolicy?,
116+
extra: extra ?? this.extra,
117+
);
118+
}
119+
86120
/// Converts this configuration to a map.
87121
Map<String, dynamic> toMap() {
88122
return {
@@ -98,4 +132,43 @@ class PasswordGeneratorConfig {
98132
'extra': extra,
99133
};
100134
}
135+
136+
@override
137+
bool operator ==(Object other) {
138+
if (identical(this, other)) return true;
139+
final collectionEquals = const DeepCollectionEquality().equals;
140+
141+
return other is PasswordGeneratorConfig &&
142+
other.length == length &&
143+
other.useUpperCase == useUpperCase &&
144+
other.useLowerCase == useLowerCase &&
145+
other.useNumbers == useNumbers &&
146+
other.useSpecialChars == useSpecialChars &&
147+
other.excludeAmbiguousChars == excludeAmbiguousChars &&
148+
other.characterSetProfile == characterSetProfile &&
149+
other.maxGenerationAttempts == maxGenerationAttempts &&
150+
other.policy == policy &&
151+
collectionEquals(other.extra, extra);
152+
}
153+
154+
@override
155+
int get hashCode {
156+
return length.hashCode ^
157+
useUpperCase.hashCode ^
158+
useLowerCase.hashCode ^
159+
useNumbers.hashCode ^
160+
useSpecialChars.hashCode ^
161+
excludeAmbiguousChars.hashCode ^
162+
characterSetProfile.hashCode ^
163+
maxGenerationAttempts.hashCode ^
164+
policy.hashCode ^
165+
const DeepCollectionEquality().hash(extra);
166+
}
167+
168+
@override
169+
String toString() {
170+
return 'PasswordGeneratorConfig(length: $length, useUpperCase: $useUpperCase, useLowerCase: $useLowerCase, useNumbers: $useNumbers, useSpecialChars: $useSpecialChars, excludeAmbiguousChars: $excludeAmbiguousChars, characterSetProfile: $characterSetProfile, maxGenerationAttempts: $maxGenerationAttempts, policy: $policy, extra: $extra)';
171+
}
101172
}
173+
174+
const Object _sentinel = Object();

lib/src/generator/ipassword_generator.dart

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import '../model/password_feedback.dart';
2+
import '../model/password_strength.dart';
23

34
/// Interface for password generators.
45
abstract interface class IPasswordGenerator {
@@ -15,6 +16,9 @@ abstract interface class IPasswordGenerator {
1516
/// Generates a strong password, retrying until validation passes.
1617
String refreshPassword();
1718

19+
/// Estimates the [PasswordStrength] of the given [password].
20+
PasswordStrength estimateStrength(String password);
21+
1822
/// Returns user-facing [PasswordFeedback] for the given [password].
1923
PasswordFeedback estimateFeedback(String password);
2024
}

lib/src/generator/password_generator.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ final class PasswordGenerator implements IPasswordGenerator {
112112
}
113113

114114
/// Estimates the [PasswordStrength] of the given [password].
115+
@override
115116
PasswordStrength estimateStrength(String password) {
116117
return _strengthEstimator.estimatePasswordStrength(
117118
_normalizer.normalize(password),

lib/src/model/character_set_profile.dart

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,4 +116,36 @@ class CharacterSetProfile {
116116
'specialCharactersNonAmbiguous': specialCharactersNonAmbiguous,
117117
};
118118
}
119+
120+
@override
121+
bool operator ==(Object other) {
122+
if (identical(this, other)) return true;
123+
124+
return other is CharacterSetProfile &&
125+
other.upperCaseLetters == upperCaseLetters &&
126+
other.lowerCaseLetters == lowerCaseLetters &&
127+
other.numbers == numbers &&
128+
other.specialCharacters == specialCharacters &&
129+
other.upperCaseLettersNonAmbiguous == upperCaseLettersNonAmbiguous &&
130+
other.lowerCaseLettersNonAmbiguous == lowerCaseLettersNonAmbiguous &&
131+
other.numbersNonAmbiguous == numbersNonAmbiguous &&
132+
other.specialCharactersNonAmbiguous == specialCharactersNonAmbiguous;
133+
}
134+
135+
@override
136+
int get hashCode {
137+
return upperCaseLetters.hashCode ^
138+
lowerCaseLetters.hashCode ^
139+
numbers.hashCode ^
140+
specialCharacters.hashCode ^
141+
upperCaseLettersNonAmbiguous.hashCode ^
142+
lowerCaseLettersNonAmbiguous.hashCode ^
143+
numbersNonAmbiguous.hashCode ^
144+
specialCharactersNonAmbiguous.hashCode;
145+
}
146+
147+
@override
148+
String toString() {
149+
return 'CharacterSetProfile(upperCaseLetters: $upperCaseLetters, lowerCaseLetters: $lowerCaseLetters, numbers: $numbers, specialCharacters: $specialCharacters, upperCaseLettersNonAmbiguous: $upperCaseLettersNonAmbiguous, lowerCaseLettersNonAmbiguous: $lowerCaseLettersNonAmbiguous, numbersNonAmbiguous: $numbersNonAmbiguous, specialCharactersNonAmbiguous: $specialCharactersNonAmbiguous)';
150+
}
119151
}

lib/src/model/password_feedback.dart

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import 'package:collection/collection.dart';
2+
13
import 'password_strength.dart';
24

35
/// User-facing feedback for password strength results.
@@ -51,4 +53,31 @@ class PasswordFeedback {
5153
'score': score,
5254
};
5355
}
56+
57+
@override
58+
bool operator ==(Object other) {
59+
if (identical(this, other)) return true;
60+
final collectionEquals = const DeepCollectionEquality().equals;
61+
62+
return other is PasswordFeedback &&
63+
other.strength == strength &&
64+
other.warning == warning &&
65+
collectionEquals(other.suggestions, suggestions) &&
66+
other.estimatedEntropy == estimatedEntropy &&
67+
other.score == score;
68+
}
69+
70+
@override
71+
int get hashCode {
72+
return strength.hashCode ^
73+
warning.hashCode ^
74+
const DeepCollectionEquality().hash(suggestions) ^
75+
estimatedEntropy.hashCode ^
76+
score.hashCode;
77+
}
78+
79+
@override
80+
String toString() {
81+
return 'PasswordFeedback(strength: $strength, warning: $warning, suggestions: $suggestions, estimatedEntropy: $estimatedEntropy, score: $score)';
82+
}
5483
}

lib/src/model/password_policy.dart

Lines changed: 50 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -79,27 +79,33 @@ class PasswordPolicy {
7979
/// Returns a copy of this policy with the given fields replaced.
8080
PasswordPolicy copyWith({
8181
int? minLength,
82-
int? maxLength,
82+
Object? maxLength = _sentinel,
8383
bool? requireUppercase,
8484
bool? requireLowercase,
8585
bool? requireNumber,
8686
bool? requireSpecial,
8787
bool? allowSpaces,
8888
bool? allowUnicode,
89-
PasswordStrength? strengthThreshold,
90-
int? scoreThreshold,
89+
Object? strengthThreshold = _sentinel,
90+
Object? scoreThreshold = _sentinel,
9191
}) {
9292
return PasswordPolicy(
9393
minLength: minLength ?? this.minLength,
94-
maxLength: maxLength ?? this.maxLength,
94+
maxLength: maxLength == _sentinel ? this.maxLength : maxLength as int?,
9595
requireUppercase: requireUppercase ?? this.requireUppercase,
9696
requireLowercase: requireLowercase ?? this.requireLowercase,
9797
requireNumber: requireNumber ?? this.requireNumber,
9898
requireSpecial: requireSpecial ?? this.requireSpecial,
9999
allowSpaces: allowSpaces ?? this.allowSpaces,
100100
allowUnicode: allowUnicode ?? this.allowUnicode,
101-
strengthThreshold: strengthThreshold ?? this.strengthThreshold,
102-
scoreThreshold: scoreThreshold ?? this.scoreThreshold,
101+
strengthThreshold:
102+
strengthThreshold == _sentinel
103+
? this.strengthThreshold
104+
: strengthThreshold as PasswordStrength?,
105+
scoreThreshold:
106+
scoreThreshold == _sentinel
107+
? this.scoreThreshold
108+
: scoreThreshold as int?,
103109
);
104110
}
105111

@@ -124,6 +130,42 @@ class PasswordPolicy {
124130
'scoreThreshold': scoreThreshold,
125131
};
126132
}
133+
134+
@override
135+
bool operator ==(Object other) {
136+
if (identical(this, other)) return true;
137+
138+
return other is PasswordPolicy &&
139+
other.minLength == minLength &&
140+
other.maxLength == maxLength &&
141+
other.requireUppercase == requireUppercase &&
142+
other.requireLowercase == requireLowercase &&
143+
other.requireNumber == requireNumber &&
144+
other.requireSpecial == requireSpecial &&
145+
other.allowSpaces == allowSpaces &&
146+
other.allowUnicode == allowUnicode &&
147+
other.strengthThreshold == strengthThreshold &&
148+
other.scoreThreshold == scoreThreshold;
149+
}
150+
151+
@override
152+
int get hashCode {
153+
return minLength.hashCode ^
154+
maxLength.hashCode ^
155+
requireUppercase.hashCode ^
156+
requireLowercase.hashCode ^
157+
requireNumber.hashCode ^
158+
requireSpecial.hashCode ^
159+
allowSpaces.hashCode ^
160+
allowUnicode.hashCode ^
161+
strengthThreshold.hashCode ^
162+
scoreThreshold.hashCode;
163+
}
164+
165+
@override
166+
String toString() {
167+
return 'PasswordPolicy(minLength: $minLength, maxLength: $maxLength, requireUppercase: $requireUppercase, requireLowercase: $requireLowercase, requireNumber: $requireNumber, requireSpecial: $requireSpecial, allowSpaces: $allowSpaces, allowUnicode: $allowUnicode, strengthThreshold: $strengthThreshold, scoreThreshold: $scoreThreshold)';
168+
}
127169
}
128170

129171
/// Builder for creating and modifying [PasswordPolicy] instances.
@@ -240,3 +282,5 @@ extension PasswordPolicyX on PasswordPolicy {
240282
return length;
241283
}
242284
}
285+
286+
const Object _sentinel = Object();

lib/src/strategy/passphrase_password_strategy.dart

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ final class PassphrasePasswordStrategy implements IPasswordGenerationStrategy {
3434
String generate(PasswordGeneratorConfig config) {
3535
validate(config);
3636

37-
final wordCount = config.length;
37+
final wordCount = config.extra['wordCount'] as int? ?? config.length;
3838
final random = _random;
3939
final words = <String>[];
4040

@@ -63,10 +63,11 @@ final class PassphrasePasswordStrategy implements IPasswordGenerationStrategy {
6363
if (_wordlist.any((word) => word.isEmpty)) {
6464
throw ArgumentError(messages.error.wordlistHasEmptyWords);
6565
}
66-
if (config.length <= 0) {
66+
final wordCount = config.extra['wordCount'] as int? ?? config.length;
67+
if (wordCount <= 0) {
6768
throw ArgumentError(messages.error.wordCountPositive);
6869
}
69-
if (!allowDuplicates && config.length > _uniqueWordlist.length) {
70+
if (!allowDuplicates && wordCount > _uniqueWordlist.length) {
7071
throw ArgumentError(messages.error.wordCountExceedsWordlist);
7172
}
7273
}

lib/src/strategy/random_password_strategy.dart

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,8 @@ final class RandomPasswordStrategy implements IPasswordGenerationStrategy {
4141
@override
4242
void validate(PasswordGeneratorConfig config) {
4343
final messages = const Messages();
44-
final minLength = PasswordPolicy.defaultMinLength;
44+
final minLength =
45+
config.policy?.minLength ?? PasswordPolicy.defaultMinLength;
4546
if (config.length < minLength) {
4647
throw ArgumentError(messages.error.passwordLengthMin(minLength));
4748
}

lib/src/strength_estimator/password_strength_estimator.dart

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,11 @@ final class PasswordStrengthEstimator implements IPasswordStrengthEstimator {
5858
characterPoolSize += specialCharacters.length;
5959
}
6060

61-
if (characterPoolSize == 0) return 0;
61+
if (characterPoolSize == 0) {
62+
// Fallback for passwords consisting entirely of unmapped characters (e.g., Unicode).
63+
// We assume a large arbitrary pool size to give credit for these characters.
64+
characterPoolSize = 100000;
65+
}
6266

6367
return password.length * log(characterPoolSize) / log(2);
6468
}

0 commit comments

Comments
 (0)