Skip to content

Commit 0286054

Browse files
leogdionclaude
andcommitted
Add comprehensive data validation to Foundation types
Implements validation for DataSourceMetadata, RestoreImageRecord, and ReviewEngagementThreshold to prevent runtime errors from invalid data. CloudKit record names are validated for ASCII compliance and length constraints, restore image hashes are validated for proper format, and environment configuration uses a type-safe wrapper with appropriate defaults. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent 5907061 commit 0286054

10 files changed

+816
-10
lines changed

Sources/BushelFoundation/DataSourceMetadata.swift

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,30 @@ public struct DataSourceMetadata: Codable, Sendable {
7070
fetchDurationSeconds: Double = 0,
7171
lastError: String? = nil
7272
) {
73+
// Validation using precondition (fail-fast approach)
74+
precondition(!sourceName.isEmpty, "sourceName cannot be empty")
75+
precondition(!recordTypeName.isEmpty, "recordTypeName cannot be empty")
76+
precondition(
77+
sourceName.unicodeScalars.allSatisfy { $0.isASCII },
78+
"sourceName must contain only ASCII characters: \(sourceName)"
79+
)
80+
precondition(
81+
recordTypeName.unicodeScalars.allSatisfy { $0.isASCII },
82+
"recordTypeName must contain only ASCII characters: \(recordTypeName)"
83+
)
84+
85+
let recordName = "metadata-\(sourceName)-\(recordTypeName)"
86+
precondition(
87+
recordName.count <= 255,
88+
"CloudKit record name exceeds 255 characters: \(recordName.count)"
89+
)
90+
91+
precondition(recordCount >= 0, "recordCount cannot be negative: \(recordCount)")
92+
precondition(
93+
fetchDurationSeconds >= 0,
94+
"fetchDurationSeconds cannot be negative: \(fetchDurationSeconds)"
95+
)
96+
7397
self.sourceName = sourceName
7498
self.recordTypeName = recordTypeName
7599
self.lastFetchedAt = lastFetchedAt
@@ -78,4 +102,54 @@ public struct DataSourceMetadata: Codable, Sendable {
78102
self.fetchDurationSeconds = fetchDurationSeconds
79103
self.lastError = lastError
80104
}
105+
106+
/// Validates DataSourceMetadata parameters without creating an instance.
107+
///
108+
/// - Parameters:
109+
/// - sourceName: The data source name
110+
/// - recordTypeName: The record type name
111+
/// - recordCount: Number of records
112+
/// - fetchDurationSeconds: Fetch duration in seconds
113+
/// - Throws: `DataSourceMetadataValidationError` if validation fails
114+
public static func validate(
115+
sourceName: String,
116+
recordTypeName: String,
117+
recordCount: Int,
118+
fetchDurationSeconds: Double
119+
) throws {
120+
try validateNames(sourceName: sourceName, recordTypeName: recordTypeName)
121+
try validateNumericFields(recordCount: recordCount, fetchDurationSeconds: fetchDurationSeconds)
122+
}
123+
124+
private static func validateNames(sourceName: String, recordTypeName: String) throws {
125+
if sourceName.isEmpty {
126+
throw DataSourceMetadataValidationError(details: .emptySourceName)
127+
}
128+
if recordTypeName.isEmpty {
129+
throw DataSourceMetadataValidationError(details: .emptyRecordTypeName)
130+
}
131+
if !sourceName.unicodeScalars.allSatisfy({ $0.isASCII }) {
132+
throw DataSourceMetadataValidationError(details: .nonASCIISourceName(sourceName))
133+
}
134+
if !recordTypeName.unicodeScalars.allSatisfy({ $0.isASCII }) {
135+
throw DataSourceMetadataValidationError(details: .nonASCIIRecordTypeName(recordTypeName))
136+
}
137+
138+
let recordName = "metadata-\(sourceName)-\(recordTypeName)"
139+
if recordName.count > 255 {
140+
throw DataSourceMetadataValidationError(details: .recordNameTooLong(recordName.count))
141+
}
142+
}
143+
144+
private static func validateNumericFields(
145+
recordCount: Int,
146+
fetchDurationSeconds: Double
147+
) throws {
148+
if recordCount < 0 {
149+
throw DataSourceMetadataValidationError(details: .negativeRecordCount(recordCount))
150+
}
151+
if fetchDurationSeconds < 0 {
152+
throw DataSourceMetadataValidationError(details: .negativeFetchDuration(fetchDurationSeconds))
153+
}
154+
}
81155
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
//
2+
// DataSourceMetadataValidationError.swift
3+
// BushelKit
4+
//
5+
// Created by Leo Dion.
6+
// Copyright © 2025 BrightDigit.
7+
//
8+
// Permission is hereby granted, free of charge, to any person
9+
// obtaining a copy of this software and associated documentation
10+
// files (the “Software”), to deal in the Software without
11+
// restriction, including without limitation the rights to use,
12+
// copy, modify, merge, publish, distribute, sublicense, and/or
13+
// sell copies of the Software, and to permit persons to whom the
14+
// Software is furnished to do so, subject to the following
15+
// conditions:
16+
//
17+
// The above copyright notice and this permission notice shall be
18+
// included in all copies or substantial portions of the Software.
19+
//
20+
// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND,
21+
// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
22+
// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
23+
// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
24+
// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
25+
// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
26+
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
27+
// OTHER DEALINGS IN THE SOFTWARE.
28+
//
29+
30+
/// Validation errors for DataSourceMetadata.
31+
public struct DataSourceMetadataValidationError: Error, Sendable {
32+
/// Specific validation error details.
33+
public enum Details: Equatable, Sendable {
34+
/// The source name is empty.
35+
case emptySourceName
36+
/// The record type name is empty.
37+
case emptyRecordTypeName
38+
/// The source name contains non-ASCII characters.
39+
case nonASCIISourceName(String)
40+
/// The record type name contains non-ASCII characters.
41+
case nonASCIIRecordTypeName(String)
42+
/// The generated CloudKit record name exceeds 255 characters.
43+
case recordNameTooLong(Int)
44+
/// The record count is negative.
45+
case negativeRecordCount(Int)
46+
/// The fetch duration is negative.
47+
case negativeFetchDuration(Double)
48+
}
49+
50+
/// The specific validation error.
51+
public let details: Details
52+
53+
/// Creates a new validation error.
54+
/// - Parameter details: The specific validation error details.
55+
public init(details: Details) {
56+
self.details = details
57+
}
58+
}

Sources/BushelFoundation/EnvironmentConfiguration.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ public struct EnvironmentConfiguration: CustomReflectable, Sendable {
7474

7575
/// The threshold of user engagement to trigger a review request.
7676
@EnvironmentProperty(Key.reviewEngagementThreshold)
77-
public var reviewEngagementThreshold: Int
77+
public var reviewEngagementThreshold: ReviewEngagementThreshold
7878

7979
/// Provides a custom mirror for the `EnvironmentConfiguration` instance.
8080
public var customMirror: Mirror {
@@ -86,7 +86,7 @@ public struct EnvironmentConfiguration: CustomReflectable, Sendable {
8686
"onboardingOveride": self.onboardingOveride,
8787
"resetApplication": self.resetApplication,
8888
"releaseVersion": self.releaseVersion,
89-
"reviewEngagementThreshold": self.reviewEngagementThreshold,
89+
"reviewEngagementThreshold": self.reviewEngagementThreshold.rawValue,
9090
]
9191
)
9292
}

Sources/BushelFoundation/RestoreImageRecord.swift

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,4 +105,60 @@ public struct RestoreImageRecord: Codable, Sendable {
105105
self.notes = notes
106106
self.sourceUpdatedAt = sourceUpdatedAt
107107
}
108+
109+
/// Validates all fields of the restore image record.
110+
///
111+
/// - Throws: `RestoreImageRecordValidationError` if any field is invalid.
112+
public func validate() throws {
113+
try Self.validateSHA256Hash(sha256Hash)
114+
try Self.validateSHA1Hash(sha1Hash)
115+
try Self.validateFileSize(fileSize)
116+
try Self.validateDownloadURL(downloadURL)
117+
}
118+
119+
/// Returns true if all fields pass validation.
120+
public var isValid: Bool {
121+
do {
122+
try validate()
123+
return true
124+
} catch {
125+
return false
126+
}
127+
}
128+
}
129+
130+
extension RestoreImageRecord {
131+
fileprivate static func isValidHexadecimal(_ string: String) -> Bool {
132+
string.allSatisfy { $0.isHexDigit }
133+
}
134+
135+
fileprivate static func validateSHA256Hash(_ hash: String) throws {
136+
guard hash.count == 64 else {
137+
throw RestoreImageRecordValidationError.invalidSHA256Hash(hash, expectedLength: 64)
138+
}
139+
guard isValidHexadecimal(hash) else {
140+
throw RestoreImageRecordValidationError.nonHexadecimalSHA256(hash)
141+
}
142+
}
143+
144+
fileprivate static func validateSHA1Hash(_ hash: String) throws {
145+
guard hash.count == 40 else {
146+
throw RestoreImageRecordValidationError.invalidSHA1Hash(hash, expectedLength: 40)
147+
}
148+
guard isValidHexadecimal(hash) else {
149+
throw RestoreImageRecordValidationError.nonHexadecimalSHA1(hash)
150+
}
151+
}
152+
153+
fileprivate static func validateFileSize(_ size: Int) throws {
154+
guard size > 0 else {
155+
throw RestoreImageRecordValidationError.nonPositiveFileSize(size)
156+
}
157+
}
158+
159+
fileprivate static func validateDownloadURL(_ url: URL) throws {
160+
guard url.scheme?.lowercased() == "https" else {
161+
throw RestoreImageRecordValidationError.insecureDownloadURL(url)
162+
}
163+
}
108164
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
//
2+
// RestoreImageRecordValidationError.swift
3+
// BushelKit
4+
//
5+
// Created by Leo Dion.
6+
// Copyright © 2025 BrightDigit.
7+
//
8+
// Permission is hereby granted, free of charge, to any person
9+
// obtaining a copy of this software and associated documentation
10+
// files (the “Software”), to deal in the Software without
11+
// restriction, including without limitation the rights to use,
12+
// copy, modify, merge, publish, distribute, sublicense, and/or
13+
// sell copies of the Software, and to permit persons to whom the
14+
// Software is furnished to do so, subject to the following
15+
// conditions:
16+
//
17+
// The above copyright notice and this permission notice shall be
18+
// included in all copies or substantial portions of the Software.
19+
//
20+
// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND,
21+
// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
22+
// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
23+
// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
24+
// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
25+
// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
26+
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
27+
// OTHER DEALINGS IN THE SOFTWARE.
28+
//
29+
30+
public import Foundation
31+
32+
/// Validation errors for RestoreImageRecord.
33+
public enum RestoreImageRecordValidationError: Error, Sendable, Equatable {
34+
/// SHA-256 hash has invalid length.
35+
case invalidSHA256Hash(String, expectedLength: Int)
36+
/// SHA-1 hash has invalid length.
37+
case invalidSHA1Hash(String, expectedLength: Int)
38+
/// SHA-256 hash contains non-hexadecimal characters.
39+
case nonHexadecimalSHA256(String)
40+
/// SHA-1 hash contains non-hexadecimal characters.
41+
case nonHexadecimalSHA1(String)
42+
/// File size is not positive.
43+
case nonPositiveFileSize(Int)
44+
/// Download URL does not use HTTPS.
45+
case insecureDownloadURL(URL)
46+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
//
2+
// ReviewEngagementThreshold.swift
3+
// BushelKit
4+
//
5+
// Created by Leo Dion.
6+
// Copyright © 2025 BrightDigit.
7+
//
8+
// Permission is hereby granted, free of charge, to any person
9+
// obtaining a copy of this software and associated documentation
10+
// files (the “Software”), to deal in the Software without
11+
// restriction, including without limitation the rights to use,
12+
// copy, modify, merge, publish, distribute, sublicense, and/or
13+
// sell copies of the Software, and to permit persons to whom the
14+
// Software is furnished to do so, subject to the following
15+
// conditions:
16+
//
17+
// The above copyright notice and this permission notice shall be
18+
// included in all copies or substantial portions of the Software.
19+
//
20+
// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND,
21+
// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
22+
// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
23+
// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
24+
// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
25+
// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
26+
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
27+
// OTHER DEALINGS IN THE SOFTWARE.
28+
//
29+
30+
public import BushelUtilities
31+
public import Foundation
32+
33+
/// The threshold of user engagement actions required before requesting an app review.
34+
public struct ReviewEngagementThreshold:
35+
RawRepresentable,
36+
EnvironmentValue,
37+
ExpressibleByIntegerLiteral,
38+
Sendable,
39+
Codable,
40+
Equatable
41+
{
42+
public typealias RawValue = Int
43+
44+
/// The default threshold value (10 interactions).
45+
public static let `default`: ReviewEngagementThreshold = 10
46+
47+
/// The underlying integer value.
48+
public let rawValue: Int
49+
50+
/// Returns the string representation for environment variable storage.
51+
public var environmentStringValue: String {
52+
"\(rawValue)"
53+
}
54+
55+
/// Creates a threshold from a raw integer value.
56+
/// - Parameter rawValue: The threshold count.
57+
public init(rawValue: Int) {
58+
self.rawValue = rawValue
59+
}
60+
61+
/// Creates a threshold from an environment variable string.
62+
/// - Parameter environmentStringValue: The string value from the environment.
63+
/// - Returns: A threshold instance, or nil if the string is not a valid integer.
64+
public init?(environmentStringValue: String) {
65+
guard let value = Int(environmentStringValue) else {
66+
return nil
67+
}
68+
self.rawValue = value
69+
}
70+
71+
/// Creates a threshold from an integer literal.
72+
/// - Parameter value: The literal integer value.
73+
public init(integerLiteral value: IntegerLiteralType) {
74+
self.rawValue = value
75+
}
76+
}

0 commit comments

Comments
 (0)