diff --git a/RouterOSAssistant/Core/Networking/RouterOSCliParser.swift b/RouterOSAssistant/Core/Networking/RouterOSCliParser.swift index eec87e7..fd71d84 100644 --- a/RouterOSAssistant/Core/Networking/RouterOSCliParser.swift +++ b/RouterOSAssistant/Core/Networking/RouterOSCliParser.swift @@ -6,7 +6,7 @@ enum RouterOSCliParser { /// Parses `/system resource print` output (colon-separated "key: value" lines). static func parseDeviceInfo(_ raw: String) -> RouterDeviceInfo { var fields: [String: String] = [:] - for line in raw.split(separator: "\n") { + for line in raw.split(whereSeparator: \.isNewline) { guard let colonIndex = line.firstIndex(of: ":") else { continue } let key = String(line[line.startIndex.. [NetworkInterface] { - raw.split(separator: "\n").compactMap { line in + raw.split(whereSeparator: \.isNewline).compactMap { line in let fields = keyValues(from: String(line)) guard let name = fields["name"] else { return nil } return NetworkInterface( diff --git a/RouterOSAssistant/Features/Wizard/Steps/Setup/SetupView.swift b/RouterOSAssistant/Features/Wizard/Steps/Setup/SetupView.swift index e388b98..492134c 100644 --- a/RouterOSAssistant/Features/Wizard/Steps/Setup/SetupView.swift +++ b/RouterOSAssistant/Features/Wizard/Steps/Setup/SetupView.swift @@ -21,11 +21,11 @@ struct SetupView: View { } else { switch viewModel.step { case .wan: - WanStepView(viewModel: viewModel, availableInterfaces: connectionService.interfaces) + WanStepView(viewModel: viewModel, availableInterfaces: configurableInterfaces) case .lan: - LanStepView(viewModel: viewModel, availableInterfaces: connectionService.interfaces) + LanStepView(viewModel: viewModel, availableInterfaces: configurableInterfaces) case .vlan: - VlanStepView(viewModel: viewModel, availableInterfaces: connectionService.interfaces) + VlanStepView(viewModel: viewModel, availableInterfaces: configurableInterfaces) case .wifi: WifiStepView(viewModel: viewModel) case .firewall: @@ -37,9 +37,17 @@ struct SetupView: View { } } .onAppear { - viewModel.prepareDefaults(from: connectionService.interfaces) + viewModel.prepareDefaults(from: configurableInterfaces) } } + + /// Loopback is never something a person should pick as WAN/LAN/VLAN base interface — + /// excluded here (not just via defaults) so a parsing hiccup elsewhere can't offer it as + /// a selectable option, which on a real hEX device caused every WAN/firewall command to + /// silently reference "lo" instead of the real WAN port. Found via live testing. + private var configurableInterfaces: [NetworkInterface] { + connectionService.interfaces.filter { $0.type.lowercased() != "loopback" } + } } #Preview { diff --git a/RouterOSAssistantTests/RouterOSCliParserTests.swift b/RouterOSAssistantTests/RouterOSCliParserTests.swift index 1f12d31..1060607 100644 --- a/RouterOSAssistantTests/RouterOSCliParserTests.swift +++ b/RouterOSAssistantTests/RouterOSCliParserTests.swift @@ -34,4 +34,22 @@ final class RouterOSCliParserTests: XCTestCase { XCTAssertFalse(interfaces[1].running) XCTAssertTrue(interfaces[1].disabled) } + + /// Regression test for a real bug found on a physical hEX device: its SSH output didn't + /// split on bare "\n", collapsing every interface into one "line" whose repeated keys + /// (name=, type=, ...) overwrote each other, leaving only the last interface ("lo"). + func testParseInterfacesHandlesCarriageReturnLineEndings() { + let raw = [ + "0 R name=ether1 default-name=ether1 type=ether mtu=1500", + "1 RS name=ether2 default-name=ether2 type=ether mtu=1500", + "5 R comment=defconf name=bridge type=bridge mtu=auto", + "6 R name=lo type=loopback mtu=65536" + ].joined(separator: "\r\n") + + let interfaces = RouterOSCliParser.parseInterfaces(raw) + + XCTAssertEqual(interfaces.count, 4) + XCTAssertEqual(interfaces.map(\.name), ["ether1", "ether2", "bridge", "lo"]) + XCTAssertEqual(interfaces.map(\.type), ["ether", "ether", "bridge", "loopback"]) + } }