p2p-swift / Sources / IrdP2P / Chunker.swift
567 words
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
import Foundation
import IrdP2PCrypto

/// Splits large application messages into `chunk` inner messages and reassembles
/// them, with bounds on concurrent assemblies, bytes and age.
enum Chunker {
    /// Raw bytes per relayed slice: base64 (×4/3) plus JSON and envelope overhead
    /// (another ×4/3 for the sealed ciphertext) must stay under the frame cap.
    static func relaySliceSize(maxFrameBytes: Int) -> Int {
        min(32_768, max(1024, Int(Double(maxFrameBytes - 512) / 1.85)))
    }

    /// Raw bytes per data-channel slice: keeps each SCTP message far below 16 KiB.
    static let dataChannelSliceSize = 8_192

    /// The chunk id of a payload: the first 8 bytes of
    /// `SHA-256(UTF8(encoding) ‖ UInt32BE(count) ‖ data)`. Derived from the content so a
    /// retransmit of the same payload resumes the receiver's assembly instead of
    /// opening a rival one; identical payloads share an assembly by design.
    static func contentId(_ data: Data, encoding: ChunkEncoding, count: Int) -> String {
        var input = Data(encoding.rawValue.utf8)
        withUnsafeBytes(of: UInt32(count).bigEndian) { input.append(contentsOf: $0) }
        input.append(data)
        return Bytes.base64URL(Bytes.sha256(input).prefix(8))
    }

    static func split(_ data: Data, encoding: ChunkEncoding, sliceSize: Int) -> [InnerMessage] {
        let count = max(1, Int((Double(data.count) / Double(sliceSize)).rounded(.up)))
        let id = contentId(data, encoding: encoding, count: count)
        return (0..<count).map { i in
            let start = data.startIndex + i * sliceSize
            let end = min(start + sliceSize, data.endIndex)
            return .chunk(id: id, index: i, count: count, encoding: encoding, data: Data(data[start..<end]))
        }
    }
}

/// Reassembles one peer's chunks. Violations of the bounds drop the assembly and say why.
struct ChunkAssembler: Sendable {
    static let maxConcurrent = 8

    struct Partial {
        var parts: [Int: Data]
        var count: Int
        var encoding: ChunkEncoding
        var bytes: Int
        var started: Duration
    }

    struct Drop: Equatable {
        var id: String
        var reason: AssemblyDropReason
    }

    let maxBytes: Int
    let maxAge: Duration
    private(set) var partials: [String: Partial] = [:]
    private var totalBytes = 0

    init(maxBytes: Int, maxAge: Duration = .seconds(60)) {
        self.maxBytes = maxBytes
        self.maxAge = maxAge
    }

    enum Result: Equatable { case incomplete, complete(encoding: ChunkEncoding, data: Data), dropped(reason: AssemblyDropReason) }

    /// Adds one slice. Callers `sweep(now:)` first so expired assemblies are reported, not just freed.
    mutating func add(id: String, index: Int, count: Int, encoding: ChunkEncoding, data: Data, now: Duration) -> Result {
        sweep(now: now)
        var p = partials[id] ?? Partial(parts: [:], count: count, encoding: encoding, bytes: 0, started: now)
        guard p.count == count, p.encoding == encoding, index >= 0, index < count else { drop(id); return .dropped(reason: .mismatched) }
        if let have = p.parts[index] {
            // ids are derived from the payload, so a retransmit repeats slices: the same bytes resume the assembly
            if have == data { return .incomplete }
            drop(id)
            return .dropped(reason: .duplicateIndex)
        }
        if partials[id] == nil, partials.count >= ChunkAssembler.maxConcurrent { return .dropped(reason: .tooManyConcurrent) }
        guard totalBytes + data.count <= maxBytes else { drop(id); return .dropped(reason: .tooManyBytes) }
        p.parts[index] = data
        p.bytes += data.count
        totalBytes += data.count
        if p.parts.count == count {
            drop(id)
            var out = Data(capacity: p.bytes)
            for i in 0..<count { out.append(p.parts[i]!) }
            return .complete(encoding: encoding, data: out)
        }
        partials[id] = p
        return .incomplete
    }

    private mutating func drop(_ id: String) {
        if let p = partials.removeValue(forKey: id) { totalBytes -= p.bytes }
    }

    /// Drops assemblies older than `maxAge` and returns them (oldest first).
    @discardableResult
    mutating func sweep(now: Duration) -> [Drop] {
        let expired = partials.filter { now - $0.value.started > maxAge }.sorted { ($0.value.started, $0.key) < ($1.value.started, $1.key) }
        for (id, _) in expired { drop(id) }
        return expired.map { Drop(id: $0.key, reason: .ageExpired) }
    }
}

static mirror of HEAD · about · clone: git clone https://ird.cod-sursa.ro/p2p-swift.git