diff --git a/README.md b/README.md index dbab885..a0a90ca 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ Instead by using `Secret` you can avoid this mistakes. By changing the type defi ```swift struct Authentication { var username: String - @Secret var token: String + @Secret var password: String } ``` @@ -52,7 +52,7 @@ print(auth) Will result in this log ``` -Authentication(username: "fake", password: Secret([REDACTED String])) +Authentication(username: "fake", _password: Secret([REDACTED String])) ``` Protecting you from accidental mistakes. @@ -65,9 +65,17 @@ auth.token.wrappedValue // This will expose the underlying `String` ## Codable support -Support for `Codable` is available if the wrapped type is already `Codable`. Note that this does not guarantee that the secret is not exposed (for example by encoding it to the disk in plain text) but you can always create a custom type with a dedicated `Codable` conformance. +Support for `Encodable` is provided by the package out of the box. +To have `Decodable` support you have to provide additional information on how to redact the value. You can easily add support for your type by confirming to the `RedactableForDecodable` protocol. +For example to automatically support `Decodable` for your `Secret` you can add: -Note that you can also create a custom type and make it only conform to `Decodable` to avoid accidental `Encodable` conformances. +```swift +extension String: RedactableForDecodable { + public static var redactor: Redactor { .default } +} +``` + +Note that this does not guarantee that the secret is not exposed (for example by encoding it to the disk in plain text) but you can always create a custom type with a dedicated `Codable` conformance. ## License diff --git a/Sources/Secrecy/Redactor.swift b/Sources/Secrecy/Redactor.swift new file mode 100644 index 0000000..f5d82f0 --- /dev/null +++ b/Sources/Secrecy/Redactor.swift @@ -0,0 +1,44 @@ +// MARK: - Redact + +public struct Redactor: Sendable { + public var redact: @Sendable (Value) -> Value + public var redactForDescription: @Sendable (Value) -> String + public var redactForDebug: @Sendable (Value) -> String + + public init( + redact: @escaping @Sendable (Value) -> Value, + redactForDescription: @escaping @Sendable (Value) -> String = { _ in "************" }, + redactForDebug: @escaping @Sendable (Value) -> String = { _ in + "Secret([REDACTED \(Value.self)])" + } + ) { + self.redact = redact + self.redactForDescription = redactForDescription + self.redactForDebug = redactForDebug + } +} + +// MARK: - Redactor + +public extension Redactor where Value == String { + static let `default` = Redactor { @Sendable _ in "************" } +} + +// MARK: - Redactor + +public extension Redactor where Value == Int { + static let `default` = Redactor { @Sendable _ in -1 } +} + +// MARK: - Redactor + +public extension Redactor where Value == Double { + static let `default` = Redactor { @Sendable _ in -1.0 } +} + +// MARK: - Redactor + +public extension Redactor where Value == Bool { + static let `defaultTrue` = Redactor { @Sendable _ in true } + static let `defaultFalse` = Redactor { @Sendable _ in false } +} diff --git a/Sources/Secrecy/Secret+Codable.swift b/Sources/Secrecy/Secret+Codable.swift deleted file mode 100644 index 52019d7..0000000 --- a/Sources/Secrecy/Secret+Codable.swift +++ /dev/null @@ -1,19 +0,0 @@ -// MARK: - Decodable - -extension Secret: Decodable where Wrapped: Decodable { - public init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - let value = try container.decode(Wrapped.self) - - self = Secret(value) - } -} - -// MARK: - Encodable - -extension Secret: Encodable where Wrapped: Encodable { - public func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - try container.encode(wrappedValue) - } -} diff --git a/Sources/Secrecy/Secret+Decodable.swift b/Sources/Secrecy/Secret+Decodable.swift new file mode 100644 index 0000000..3ad19a0 --- /dev/null +++ b/Sources/Secrecy/Secret+Decodable.swift @@ -0,0 +1,14 @@ +// MARK: - Decodable + +extension Secret: Decodable where Wrapped: Decodable & RedactableForDecodable { + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let value = try container.decode(Wrapped.self) + + self = Secret(value, redactor: Wrapped.redactor) + } +} + +public protocol RedactableForDecodable { + static var redactor: Redactor { get } +} diff --git a/Sources/Secrecy/Secret+Encodable.swift b/Sources/Secrecy/Secret+Encodable.swift new file mode 100644 index 0000000..70ba8d3 --- /dev/null +++ b/Sources/Secrecy/Secret+Encodable.swift @@ -0,0 +1,8 @@ +// MARK: - Encodable + +extension Secret: Encodable where Wrapped: Encodable { + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(projectedValue.value) + } +} diff --git a/Sources/Secrecy/Secret+Literal.swift b/Sources/Secrecy/Secret+Literal.swift index fb7f152..1f0407d 100644 --- a/Sources/Secrecy/Secret+Literal.swift +++ b/Sources/Secrecy/Secret+Literal.swift @@ -2,7 +2,7 @@ extension Secret: ExpressibleByUnicodeScalarLiteral where Wrapped == String { public init(unicodeScalarLiteral value: Wrapped) { - self.init(value) + self.init(value, redactor: .default) } } @@ -10,7 +10,7 @@ extension Secret: ExpressibleByUnicodeScalarLiteral where Wrapped == String { extension Secret: ExpressibleByExtendedGraphemeClusterLiteral where Wrapped == String { public init(extendedGraphemeClusterLiteral value: Wrapped) { - self.init(value) + self.init(value, redactor: .default) } } @@ -18,7 +18,7 @@ extension Secret: ExpressibleByExtendedGraphemeClusterLiteral where Wrapped == S extension Secret: ExpressibleByStringLiteral where Wrapped == String { public init(stringLiteral value: Wrapped) { - self.init(value) + self.init(value, redactor: .default) } } @@ -26,7 +26,7 @@ extension Secret: ExpressibleByStringLiteral where Wrapped == String { extension Secret: ExpressibleByIntegerLiteral where Wrapped == Int { public init(integerLiteral value: Int) { - self.init(value) + self.init(value, redactor: .default) } } @@ -34,7 +34,7 @@ extension Secret: ExpressibleByIntegerLiteral where Wrapped == Int { extension Secret: ExpressibleByFloatLiteral where Wrapped == Double { public init(floatLiteral value: Double) { - self.init(value) + self.init(value, redactor: .default) } } @@ -42,31 +42,52 @@ extension Secret: ExpressibleByFloatLiteral where Wrapped == Double { extension Secret: ExpressibleByBooleanLiteral where Wrapped == Bool { public init(booleanLiteral value: Bool) { - self.init(value) + self.init(value, redactor: .defaultFalse) } } // MARK: - ExpressibleByArrayLiteral - -extension Secret: ExpressibleByArrayLiteral where Wrapped: _WrapperForArrayLiteralElements { +extension Secret: ExpressibleByArrayLiteral where Wrapped: _InitializableByArrayLiteralElements { public init(arrayLiteral elements: Wrapped.ArrayLiteralElement...) { - self.init(Wrapped(elements)) + self.init(Wrapped(elements), redactor: Redactor{ $0.anonymizeValues() }) } } /// A helper protocol used to enable wrapper types to conform to `ExpressibleByArrayLiteral`. /// Seen in https://github.com/apollographql/apollo-ios/blob/014660b14262df15f0d2c7b7e973d0a53be27b7e/Sources/ApolloAPI/GraphQLNullable.swift#L188 /// Used by ``Secret.init(arrayLiteral:)`` -public protocol _WrapperForArrayLiteralElements: ExpressibleByArrayLiteral { +public protocol _InitializableByArrayLiteralElements: ExpressibleByArrayLiteral { init(_ array: [ArrayLiteralElement]) + + var anonymizeValues: @Sendable () -> Self { get } +} +extension Array: _InitializableByArrayLiteralElements +where Element: InitializableByLiteralType { + public var anonymizeValues: @Sendable () -> Array { + { @Sendable in map(Element.redactor.redact) } + } } -extension Array: _WrapperForArrayLiteralElements {} -// MARK: - ExpressibleByDictionaryLiteral +public protocol InitializableByLiteralType { + static var redactor: Redactor { get } +} + +extension String: InitializableByLiteralType { + public static var redactor: Redactor { .default } +} +extension Int: InitializableByLiteralType { + public static var redactor: Redactor { .default } +} + +extension Double: InitializableByLiteralType { + public static var redactor: Redactor { .default } +} + +// MARK: - ExpressibleByDictionaryLiteral extension Secret: ExpressibleByDictionaryLiteral where Wrapped: _WrapperForDictionaryLiteralElements { public init(dictionaryLiteral elements: (Wrapped.Key, Wrapped.Value)...) { - self.init(Wrapped(elements)) + self.init(Wrapped(elements), redactor: Redactor { $0.anonymizeValues() }) } } @@ -75,10 +96,21 @@ extension Secret: ExpressibleByDictionaryLiteral where Wrapped: _WrapperForDicti /// Used by ``Secret.init(dictionaryLiteral:)`` public protocol _WrapperForDictionaryLiteralElements: ExpressibleByDictionaryLiteral { init(_ dictionary: [(Key, Value)]) + + var anonymizeValues: @Sendable () -> Self { get } } -extension Dictionary: _WrapperForDictionaryLiteralElements { +extension Dictionary: _WrapperForDictionaryLiteralElements +where Key: InitializableByLiteralType, Value: InitializableByLiteralType { public init(_ elements: [(Key, Value)]) { self.init(uniqueKeysWithValues: elements) } + + public var anonymizeValues: @Sendable () -> Dictionary { + { @Sendable in + first + .map { (key, value) in [Key.redactor.redact(key): Value.redactor.redact(value)] } + ?? [:] + } + } } diff --git a/Sources/Secrecy/Secret.swift b/Sources/Secrecy/Secret.swift index 7ba6519..8fbeace 100644 --- a/Sources/Secrecy/Secret.swift +++ b/Sources/Secrecy/Secret.swift @@ -3,17 +3,55 @@ public struct Secret { /// The value that should be treated with care. private let value: Wrapped + private let redactor: Redactor + + /// Creates a new secret by wrapping the provided value. + /// + /// - Parameter wrappedValue: The value to keep secret + /// - Parameter redactor: The `Redactor` that masks the secret + /// + public init(_ value: Wrapped, redactor: Redactor) { + self.value = value + self.redactor = redactor + } /// The underlying secret that we are trying to keep hidden public var wrappedValue: Wrapped { - value + redactor.redact(value) } - /// Creates a new secret by wrapping the provided value. - /// - /// - Parameter value: The value to keep secret - public init(_ value: Wrapped) { + public var projectedValue: UnwrappedSecret { + UnwrappedSecret(value) + } + + public struct UnwrappedSecret { + public let value: Wrapped + + public init(_ value: Wrapped) { + self.value = value + } + } +} + +public extension Secret { + init(_ value: Wrapped) where Wrapped == String { + self.value = value + self.redactor = .default + } + + init(_ value: Wrapped) where Wrapped == Int { + self.value = value + self.redactor = .default + } + + init(_ value: Wrapped) where Wrapped == Double { + self.value = value + self.redactor = .default + } + + init(_ value: Wrapped) where Wrapped == Bool { self.value = value + self.redactor = .defaultFalse } } @@ -21,7 +59,7 @@ public struct Secret { extension Secret: CustomStringConvertible { public var description: String { - "************" + redactor.redactForDescription(value) } } @@ -29,7 +67,7 @@ extension Secret: CustomStringConvertible { extension Secret: CustomDebugStringConvertible { public var debugDescription: String { - "Secret([REDACTED \(Wrapped.self)])" + redactor.redactForDebug(value) } } diff --git a/Tests/SecrecyTests/SecrecyTests.swift b/Tests/SecrecyTests/SecrecyTests.swift index 4463aa8..820f7bd 100644 --- a/Tests/SecrecyTests/SecrecyTests.swift +++ b/Tests/SecrecyTests/SecrecyTests.swift @@ -1,7 +1,20 @@ import XCTest -@testable import Secrecy +import Secrecy final class SecrecyTests: XCTestCase { + func testReadME() { + struct Authentication { + var username: String + @Secret var password: String + } + + let container = Authentication(username: "fake", password: "abc123") + XCTAssertEqual( + "\(container)", + "Authentication(username: \"fake\", _password: Secret([REDACTED String]))" + ) + } + func testCustomStringConvertible() { let wrappedBool = true let wrappedString = "super_secret" @@ -44,7 +57,7 @@ final class SecrecyTests: XCTestCase { let credentials = try JSONDecoder().decode(FakeCredentials.self, from: data) XCTAssertEqual(credentials.password.debugDescription, "Secret([REDACTED String])") - XCTAssertEqual(credentials.password.wrappedValue, "very_secret") + XCTAssertEqual(credentials.password.projectedValue.value, "very_secret") } func testEncoding() throws { @@ -66,7 +79,10 @@ final class SecrecyTests: XCTestCase { let decodedSecret = try JSONDecoder().decode(FakeCredentials.self, from: data) XCTAssertEqual(secret.username, decodedSecret.username) - XCTAssertEqual(secret.password.wrappedValue, decodedSecret.password.wrappedValue) + XCTAssertEqual( + secret.password.projectedValue.value, + decodedSecret.password.projectedValue.value + ) } func testAutomaticStringConversion() { @@ -94,21 +110,60 @@ final class SecrecyTests: XCTestCase { XCTAssertEqual((10 as Secret).debugDescription, "Secret([REDACTED Int])") XCTAssertEqual((10.0 as Secret).debugDescription, "Secret([REDACTED Double])") XCTAssertEqual((true as Secret).debugDescription, "Secret([REDACTED Bool])") + + struct ArrayLiteraWrapper: CustomDebugStringConvertible { + @Secret var value: [String] + + var debugDescription: String { _value.debugDescription } + } XCTAssertEqual( - (["password", "token"] as Secret<[String]>).debugDescription, + ArrayLiteraWrapper(value: ["password", "token"]).debugDescription, "Secret([REDACTED Array])" ) + struct DictionaryLiteraWrapper: CustomDebugStringConvertible { + @Secret var value: [String: Int] + + var debugDescription: String { _value.debugDescription } + } XCTAssertEqual( - (["username": 1, "password": 2] as Secret<[String: Int]>).debugDescription, + DictionaryLiteraWrapper(value: ["username": 1, "password": 2]).debugDescription, "Secret([REDACTED Dictionary])" ) } + + func testValueIsNotExposed() { + let container = PropertyWrapperTest(username: "username", password: "password") + XCTAssertFalse(container.password.contains("password")) + XCTAssertEqual(container.$password.value, "password") + } + + func testCustomDebugRedactorIsUsed() { + struct CustomRedactorWrapped { + @Secret var password: String + } + + let custom = Secret( + "very_secret", + redactor: Redactor( + redact: { _ in "************" }, + redactForDescription: { _ in "*" }, + redactForDebug: { _ in "_" } + ) + ) + + XCTAssertEqual(custom.description, "*") + XCTAssertEqual(custom.debugDescription, "_") + XCTAssertEqual(custom.projectedValue.value, "very_secret") + } } private struct FakeCredentials: Codable { var username: String var password: Secret } +extension String: RedactableForDecodable { + public static var redactor: Redactor { .default } +} private struct PropertyWrapperTest { var username: String