86 lines
3.3 KiB
Swift
86 lines
3.3 KiB
Swift
import Foundation
|
|
|
|
/// The normalized portion of `os-release` that is safe and useful after an SSH
|
|
/// session ends. It deliberately excludes a version: host artwork represents a
|
|
/// distribution family, and persisting every point upgrade would create noisy
|
|
/// Vault mutations without changing presentation.
|
|
public nonisolated struct DetectedDistribution: Codable, Hashable, Sendable {
|
|
public var id: String
|
|
public var likeIDs: [String]
|
|
|
|
public init?(id: String, likeIDs: [String] = []) {
|
|
guard let id = RemoteSystemIdentifier.normalized(id) else { return nil }
|
|
self.id = id
|
|
self.likeIDs = RemoteSystemIdentifier.normalizedList(likeIDs)
|
|
.filter { $0 != id }
|
|
}
|
|
|
|
private enum CodingKeys: String, CodingKey { case id, likeIDs }
|
|
|
|
public init(from decoder: any Decoder) throws {
|
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
let proposedID = try container.decode(String.self, forKey: .id)
|
|
let proposedLikeIDs =
|
|
try container.decodeIfPresent(
|
|
[String].self, forKey: .likeIDs
|
|
) ?? []
|
|
guard let value = Self(id: proposedID, likeIDs: proposedLikeIDs) else {
|
|
throw DecodingError.dataCorruptedError(
|
|
forKey: .id, in: container,
|
|
debugDescription: "Invalid distribution identifier"
|
|
)
|
|
}
|
|
self = value
|
|
}
|
|
|
|
public func encode(to encoder: any Encoder) throws {
|
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
|
try container.encode(id, forKey: .id)
|
|
if !likeIDs.isEmpty { try container.encode(likeIDs, forKey: .likeIDs) }
|
|
}
|
|
}
|
|
|
|
/// Shared validation for remote system tokens and Vault-restored distribution
|
|
/// identifiers. Keeping one grammar prevents a synced Host from bypassing the
|
|
/// validation applied to live SSH probe output.
|
|
public nonisolated enum RemoteSystemIdentifier {
|
|
public static let maximumTokenBytes = 64
|
|
public static let maximumListCount = 8
|
|
public static let maximumListBytes = 256
|
|
|
|
public static func sanitized(_ proposed: String?) -> String? {
|
|
guard let proposed else { return nil }
|
|
let value = proposed.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !value.isEmpty, value.utf8.count <= maximumTokenBytes,
|
|
value.unicodeScalars.allSatisfy({ scalar in
|
|
switch scalar.value {
|
|
case 0x30...0x39, 0x41...0x5A, 0x61...0x7A: true
|
|
case 0x2B, 0x2D, 0x2E, 0x5F: true // + - . _
|
|
default: false
|
|
}
|
|
})
|
|
else { return nil }
|
|
return value
|
|
}
|
|
|
|
public static func normalized(_ proposed: String?) -> String? {
|
|
sanitized(proposed)?.lowercased()
|
|
}
|
|
|
|
public static func normalizedList(_ proposed: [String]) -> [String] {
|
|
var seen = Set<String>()
|
|
var result: [String] = []
|
|
var byteCount = 0
|
|
for raw in proposed {
|
|
guard result.count < maximumListCount, let value = normalized(raw),
|
|
seen.insert(value).inserted
|
|
else { continue }
|
|
let nextByteCount = byteCount + value.utf8.count + (result.isEmpty ? 0 : 1)
|
|
guard nextByteCount <= maximumListBytes else { break }
|
|
result.append(value)
|
|
byteCount = nextByteCount
|
|
}
|
|
return result
|
|
}
|
|
}
|