Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 102 additions & 1 deletion Sources/BushelFoundation/DataSourceMetadata.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ public struct DataSourceMetadata: Codable, Sendable {

/// CloudKit record name for this metadata entry
public var recordName: String {
"metadata-\(sourceName)-\(recordTypeName)"
Self.makeRecordName(sourceName: sourceName, recordTypeName: recordTypeName)
}

// MARK: Lifecycle
Expand All @@ -70,6 +70,25 @@ public struct DataSourceMetadata: Codable, Sendable {
fetchDurationSeconds: Double = 0,
lastError: String? = nil
) {
// Validation using precondition (fail-fast approach)
precondition(
Self.isValidSourceName(sourceName),
"sourceName must be non-empty and contain only ASCII characters")
precondition(
Self.isValidRecordTypeName(recordTypeName),
"recordTypeName must be non-empty and contain only ASCII characters")

let recordName = Self.makeRecordName(sourceName: sourceName, recordTypeName: recordTypeName)
precondition(
Self.isValidRecordName(recordName),
"CloudKit record name exceeds 255 characters: \(recordName.count)")

precondition(
Self.isValidRecordCount(recordCount), "recordCount cannot be negative: \(recordCount)")
precondition(
Self.isValidFetchDuration(fetchDurationSeconds),
"fetchDurationSeconds cannot be negative: \(fetchDurationSeconds)")

self.sourceName = sourceName
self.recordTypeName = recordTypeName
self.lastFetchedAt = lastFetchedAt
Expand All @@ -78,4 +97,86 @@ public struct DataSourceMetadata: Codable, Sendable {
self.fetchDurationSeconds = fetchDurationSeconds
self.lastError = lastError
}

/// Validates DataSourceMetadata parameters without creating an instance.
///
/// - Parameters:
/// - sourceName: The data source name
/// - recordTypeName: The record type name
/// - recordCount: Number of records
/// - fetchDurationSeconds: Fetch duration in seconds
/// - Throws: `DataSourceMetadataValidationError` if validation fails
public static func validate(
sourceName: String,
recordTypeName: String,
recordCount: Int,
fetchDurationSeconds: Double
) throws {
try validateNames(sourceName: sourceName, recordTypeName: recordTypeName)
try validateNumericFields(recordCount: recordCount, fetchDurationSeconds: fetchDurationSeconds)
}

// MARK: Private Validation Helpers

private static func isValidSourceName(_ name: String) -> Bool {
!name.isEmpty && name.unicodeScalars.allSatisfy { $0.isASCII }
}

private static func isValidRecordTypeName(_ name: String) -> Bool {
!name.isEmpty && name.unicodeScalars.allSatisfy { $0.isASCII }
}

private static func isValidRecordName(_ name: String) -> Bool {
name.count <= 255
}

private static func isValidRecordCount(_ count: Int) -> Bool {
count >= 0
}

private static func isValidFetchDuration(_ duration: Double) -> Bool {
duration >= 0
}

/// Constructs a CloudKit record name from source and record type names.
///
/// - Parameters:
/// - sourceName: The data source name
/// - recordTypeName: The record type name
/// - Returns: A CloudKit record name in the format "metadata-{sourceName}-{recordTypeName}"
private static func makeRecordName(sourceName: String, recordTypeName: String) -> String {
"metadata-\(sourceName)-\(recordTypeName)"
}

private static func validateNames(sourceName: String, recordTypeName: String) throws {
if sourceName.isEmpty {
throw DataSourceMetadataValidationError(details: .emptySourceName)
}
if recordTypeName.isEmpty {
throw DataSourceMetadataValidationError(details: .emptyRecordTypeName)
}
if !isValidSourceName(sourceName) {
throw DataSourceMetadataValidationError(details: .nonASCIISourceName(sourceName))
}
if !isValidRecordTypeName(recordTypeName) {
throw DataSourceMetadataValidationError(details: .nonASCIIRecordTypeName(recordTypeName))
}

let recordName = Self.makeRecordName(sourceName: sourceName, recordTypeName: recordTypeName)
if !isValidRecordName(recordName) {
throw DataSourceMetadataValidationError(details: .recordNameTooLong(recordName.count))
}
}

private static func validateNumericFields(
recordCount: Int,
fetchDurationSeconds: Double
) throws {
if !isValidRecordCount(recordCount) {
throw DataSourceMetadataValidationError(details: .negativeRecordCount(recordCount))
}
if !isValidFetchDuration(fetchDurationSeconds) {
throw DataSourceMetadataValidationError(details: .negativeFetchDuration(fetchDurationSeconds))
}
}
}
58 changes: 58 additions & 0 deletions Sources/BushelFoundation/DataSourceMetadataValidationError.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
//
// DataSourceMetadataValidationError.swift
// BushelKit
//
// Created by Leo Dion.
// Copyright © 2025 BrightDigit.
//
// Permission is hereby granted, free of charge, to any person
// obtaining a copy of this software and associated documentation
// files (the “Software”), to deal in the Software without
// restriction, including without limitation the rights to use,
// copy, modify, merge, publish, distribute, sublicense, and/or
// sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following
// conditions:
//
// The above copyright notice and this permission notice shall be
// included in all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND,
// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
// OTHER DEALINGS IN THE SOFTWARE.
//

/// Validation errors for DataSourceMetadata.
public struct DataSourceMetadataValidationError: Error, Sendable {
/// Specific validation error details.
public enum Details: Equatable, Sendable {
/// The source name is empty.
case emptySourceName
/// The record type name is empty.
case emptyRecordTypeName
/// The source name contains non-ASCII characters.
case nonASCIISourceName(String)
/// The record type name contains non-ASCII characters.
case nonASCIIRecordTypeName(String)
/// The generated CloudKit record name exceeds 255 characters.
case recordNameTooLong(Int)
/// The record count is negative.
case negativeRecordCount(Int)
/// The fetch duration is negative.
case negativeFetchDuration(Double)
}

/// The specific validation error.
public let details: Details

/// Creates a new validation error.
/// - Parameter details: The specific validation error details.
public init(details: Details) {
self.details = details
}
}
4 changes: 2 additions & 2 deletions Sources/BushelFoundation/EnvironmentConfiguration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ public struct EnvironmentConfiguration: CustomReflectable, Sendable {

/// The threshold of user engagement to trigger a review request.
@EnvironmentProperty(Key.reviewEngagementThreshold)
public var reviewEngagementThreshold: Int
public var reviewEngagementThreshold: ReviewEngagementThreshold

/// Indicates whether to trigger tracking permissions request.
@EnvironmentProperty(Key.triggerTrackingPermissionsRequest)
Expand All @@ -95,7 +95,7 @@ public struct EnvironmentConfiguration: CustomReflectable, Sendable {
"onboardingOveride": self.onboardingOveride,
"resetApplication": self.resetApplication,
"releaseVersion": self.releaseVersion,
"reviewEngagementThreshold": self.reviewEngagementThreshold,
"reviewEngagementThreshold": self.reviewEngagementThreshold.rawValue,
"triggerTrackingPermissionsRequest": self.triggerTrackingPermissionsRequest,
]
)
Expand Down
89 changes: 89 additions & 0 deletions Sources/BushelFoundation/RestoreImageRecord.swift
Original file line number Diff line number Diff line change
Expand Up @@ -105,4 +105,93 @@ public struct RestoreImageRecord: Codable, Sendable {
self.notes = notes
self.sourceUpdatedAt = sourceUpdatedAt
}

/// Validates all fields of the restore image record.
///
/// - Throws: `RestoreImageRecordValidationError` if any field is invalid.
public func validate() throws {
try Self.validateSHA256Hash(sha256Hash)
try Self.validateSHA1Hash(sha1Hash)
try Self.validateFileSize(fileSize)
try Self.validateDownloadURL(downloadURL)
}

/// Returns true if all fields pass validation.
public var isValid: Bool {
do {
try validate()
return true
} catch {
return false
}
}
}

extension RestoreImageRecord {
/// Validates that a string contains only lowercase hexadecimal characters.
///
/// - Parameter string: The string to validate. Must be already normalized to lowercase.
/// - Returns: `true` if the string contains only lowercase hex digits (0-9, a-f), `false` otherwise.
///
/// - Note: This method expects the input to be pre-normalized to lowercase.
/// Use `string.lowercased()` before calling this method if the input may contain uppercase characters.
fileprivate static func isValidHexadecimal(_ string: String) -> Bool {
string.allSatisfy { $0.isHexDigit && ($0.isLowercase || $0.isNumber) }
}

/// Validates a cryptographic hash string.
///
/// - Parameters:
/// - hash: The hash string to validate
/// - expectedLength: Expected character length (64 for SHA-256, 40 for SHA-1)
/// - invalidLengthError: Error to throw if length is incorrect
/// - nonHexError: Error to throw if non-hexadecimal characters present
/// - Throws: Provided error if validation fails
private static func validateHash(
_ hash: String,
expectedLength: Int,
invalidLengthError: @autoclosure () -> RestoreImageRecordValidationError,
nonHexError: @autoclosure () -> RestoreImageRecordValidationError
) throws {
let normalizedHash = hash.lowercased()
guard normalizedHash.count == expectedLength else {
throw invalidLengthError()
}
guard isValidHexadecimal(normalizedHash) else {
throw nonHexError()
}
}

fileprivate static func validateSHA256Hash(_ hash: String) throws {
try validateHash(
hash,
expectedLength: 64,
invalidLengthError: .invalidSHA256Hash(hash, expectedLength: 64),
nonHexError: .nonHexadecimalSHA256(hash)
)
}

fileprivate static func validateSHA1Hash(_ hash: String) throws {
try validateHash(
hash,
expectedLength: 40,
invalidLengthError: .invalidSHA1Hash(hash, expectedLength: 40),
nonHexError: .nonHexadecimalSHA1(hash)
)
}

fileprivate static func validateFileSize(_ size: Int) throws {
guard size > 0 else {
throw RestoreImageRecordValidationError.nonPositiveFileSize(size)
}
}

fileprivate static func validateDownloadURL(_ url: URL) throws {
guard let scheme = url.scheme?.lowercased() else {
throw RestoreImageRecordValidationError.missingURLScheme(url)
}
guard scheme == "https" else {
throw RestoreImageRecordValidationError.insecureDownloadURL(url)
}
}
}
48 changes: 48 additions & 0 deletions Sources/BushelFoundation/RestoreImageRecordValidationError.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
//
// RestoreImageRecordValidationError.swift
// BushelKit
//
// Created by Leo Dion.
// Copyright © 2025 BrightDigit.
//
// Permission is hereby granted, free of charge, to any person
// obtaining a copy of this software and associated documentation
// files (the “Software”), to deal in the Software without
// restriction, including without limitation the rights to use,
// copy, modify, merge, publish, distribute, sublicense, and/or
// sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following
// conditions:
//
// The above copyright notice and this permission notice shall be
// included in all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND,
// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
// OTHER DEALINGS IN THE SOFTWARE.
//

public import Foundation

/// Validation errors for RestoreImageRecord.
public enum RestoreImageRecordValidationError: Error, Sendable, Equatable {
/// SHA-256 hash has invalid length.
case invalidSHA256Hash(String, expectedLength: Int)
/// SHA-1 hash has invalid length.
case invalidSHA1Hash(String, expectedLength: Int)
/// SHA-256 hash contains non-hexadecimal characters.
case nonHexadecimalSHA256(String)
/// SHA-1 hash contains non-hexadecimal characters.
case nonHexadecimalSHA1(String)
/// File size is not positive.
case nonPositiveFileSize(Int)
/// Download URL is missing a scheme.
case missingURLScheme(URL)
/// Download URL does not use HTTPS.
case insecureDownloadURL(URL)
}
Loading
Loading