Skip to content
Draft
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
11 changes: 11 additions & 0 deletions Sources/IRCKit/Extensions/DateFormatter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,14 @@ extension DateFormatter {
return formatter
}()
}

extension Date {
/// Parses an IRC server timestamp, expressed as whole seconds since the Unix epoch (e.g. the
/// `setat` field of RPL_TOPICWHOTIME and the creation time of RPL_CREATIONTIME).
static func fromUnixTimestamp(_ string: String) -> Date? {
guard let seconds = TimeInterval(string) else {
return nil
}
return Date(timeIntervalSince1970: seconds)
}
}
100 changes: 82 additions & 18 deletions Sources/IRCKit/Extensions/String.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,24 +35,6 @@ extension Array where Element == String {
}
}

extension Dictionary where Key == String, Value == String {
func keyValueString(joinedBy separator: String) -> String {
return self.map({ (kv: (String, String)) -> String in
let (key, value) = kv
return "\(key)=\(value)"
}).joined(separator: separator)
}
}

extension Dictionary where Key == String, Value == String? {
func keyValueString(joinedBy separator: String) -> String {
return self.map({ (kv: (String, String?)) -> String in
let (key, value) = kv
return value != nil ? "\(key)=\(value!)" : key
}).joined(separator: separator)
}
}

extension Array where Element == UInt8 {
func xor(with key: [UInt8]) -> String? {
if self.isEmpty {
Expand All @@ -77,6 +59,44 @@ extension String {
return tokens.keyValuePairs()
}

/// Removes the characters forbidden inside an IRC parameter (NUL, CR, LF) so caller-supplied
/// text cannot terminate the line and inject additional commands. Filtering operates on unicode
/// scalars because Swift treats a CR+LF pair as a single grapheme cluster.
func ircParameterSanitized() -> String {
return String(String.UnicodeScalarView(self.unicodeScalars.filter({
$0 != "\0" && $0 != "\r" && $0 != "\n"
})))
}

/// Folds a nickname or channel name to a canonical lowercase form for case-insensitive
/// comparison, honouring the server's CASEMAPPING (RFC 2812 §2.2). `rfc1459` additionally treats
/// `[]\~` as the uppercase forms of `{}|^`; `rfc1459-strict` does the same except for `~`;
/// `ascii` folds only A–Z. Defaults to `rfc1459` when the server has not advertised a mapping.
func ircCaseFolded(mapping: String?) -> String {
let mapping = mapping?.lowercased() ?? "rfc1459"
let foldsBrackets = mapping == "rfc1459" || mapping == "rfc1459-strict"
let foldsTilde = mapping == "rfc1459"

var folded = String.UnicodeScalarView()
for scalar in self.unicodeScalars {
switch scalar.value {
case 0x41...0x5A: // A–Z
folded.append(UnicodeScalar(scalar.value + 32) ?? scalar)
case 0x5B where foldsBrackets: // [
folded.append("{")
case 0x5D where foldsBrackets: // ]
folded.append("}")
case 0x5C where foldsBrackets: // \
folded.append("|")
case 0x7E where foldsTilde: // ~
folded.append("^")
default:
folded.append(scalar)
}
}
return String(folded)
}

static func random(length: Int = 20) -> String {
let base = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
var randomString: String = ""
Expand All @@ -92,4 +112,48 @@ extension String {
return randomString
}

/// Escapes this string for use as an IRCv3 message-tag value, per the message-tags spec:
/// `\` → `\\`, `;` → `\:`, space → `\s`, CR → `\r`, LF → `\n`.
func ircTagValueEscaped() -> String {
var escaped = ""
escaped.reserveCapacity(self.count)
for character in self {
switch character {
case "\\": escaped += "\\\\"
case ";": escaped += "\\:"
case " ": escaped += "\\s"
case "\r": escaped += "\\r"
case "\n": escaped += "\\n"
default: escaped.append(character)
}
}
return escaped
}

/// Reverses IRCv3 message-tag value escaping. Unrecognised escape sequences resolve to the
/// escaped character itself, and a trailing lone backslash is dropped, both per the spec.
func ircTagValueUnescaped() -> String {
var unescaped = ""
unescaped.reserveCapacity(self.count)
var iterator = self.makeIterator()
while let character = iterator.next() {
guard character == "\\" else {
unescaped.append(character)
continue
}
guard let escaped = iterator.next() else {
break
}
switch escaped {
case ":": unescaped.append(";")
case "s": unescaped.append(" ")
case "\\": unescaped.append("\\")
case "r": unescaped.append("\r")
case "n": unescaped.append("\n")
default: unescaped.append(escaped)
}
}
return unescaped
}

}
13 changes: 6 additions & 7 deletions Sources/IRCKit/IRCChannel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ public class IRCChannel: Equatable, @unchecked Sendable {

func set(nickname: String, member: IRCUser) {
let existingMemberIndex = self.members.firstIndex(where: {
$0.nickname == nickname
self.client.isSameName($0.nickname, nickname)
})
if let existingMemberIndex = existingMemberIndex {
self.members[existingMemberIndex] = member
Expand All @@ -86,7 +86,7 @@ public class IRCChannel: Equatable, @unchecked Sendable {

func set(member: IRCUser) {
let existingMemberIndex = self.members.firstIndex(where: {
$0.nickname == member.nickname
self.client.isSameName($0.nickname, member.nickname)
})
if let existingMemberIndex = existingMemberIndex {
self.members[existingMemberIndex] = member
Expand All @@ -97,13 +97,13 @@ public class IRCChannel: Equatable, @unchecked Sendable {

func remove(member: IRCUser) {
self.members.removeAll(where: {
member.nickname == $0.nickname
self.client.isSameName(member.nickname, $0.nickname)
})
}

func remove(named nickname: String) {
self.members.removeAll(where: {
$0.nickname == nickname
self.client.isSameName($0.nickname, nickname)
})
}

Expand All @@ -112,15 +112,14 @@ public class IRCChannel: Equatable, @unchecked Sendable {
}

public func member(named nickname: String) -> IRCUser? {
let nickname = nickname.lowercased()
return members.first(where: {
$0.nickname.lowercased() == nickname
self.client.isSameName($0.nickname, nickname)
})
}

public func member(fromSender sender: IRCSender) -> IRCUser? {
return members.first(where: {
$0.nickname == sender.nickname
self.client.isSameName($0.nickname, sender.nickname)
})
}

Expand Down
9 changes: 6 additions & 3 deletions Sources/IRCKit/IRCClient/Authentication/Authentication.swift
Original file line number Diff line number Diff line change
Expand Up @@ -65,18 +65,21 @@ extension IRCClient {
}

func handleAuthenticationCompleted(message: IRCMessage) {
self.send(command: .CAP, parameters: ["END"])
self.endCapabilityNegotiation()
}

func handleAccountChangeServerEvent(message: IRCMessage) {
guard let sender = message.sender else {
return
}

guard let account = message.parameters[safe: 0] else {
return
}

for channel in self.channels {
if let member = channel.member(fromSender: sender) {

member.account = message.parameters[0] != "*" ? message.parameters[0] : nil
member.account = account != "*" ? account : nil
channel.set(member: member)
}
}
Expand Down
5 changes: 4 additions & 1 deletion Sources/IRCKit/IRCClient/Authentication/SASLExternal.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,10 @@ class ExternalSASLHandler: SASLHandler {
}

func handleResponse(message: IRCMessage) {
if message.parameters[0] == "+" {
guard let response = message.parameters[safe: 0] else {
return
}
if response == "+" {
client.sendAuthenticate(message: "+")
} else {
client.abortSaslAuthentication()
Expand Down
5 changes: 4 additions & 1 deletion Sources/IRCKit/IRCClient/Authentication/SASLPlainText.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,10 @@ class PlainTextSASLHandler: SASLHandler {
}

func handleResponse(message: IRCMessage) {
if message.parameters[0] == "+" {
guard let response = message.parameters[safe: 0] else {
return
}
if response == "+" {
guard let password = client.configuration.authenticationPassword else {
client.abortSaslAuthentication()
return
Expand Down
Loading