captureRows.ts builds Java's rows per frame (Lauscher.datenVorbereiten): Ethernet; ARP, ICMP or IP; TCP or UDP; the data with Java's protocol recognition; with addresses and ports or ports only. The comment stays structured; captureCommentText says it with given words and numbers. ScenarioNetwork writes Java's finished tables per card (Lauscher.getDaten, German locale); all runtime runs are recorded again, NatRun has ttlOut. expectSameCaptures compares both views for every frame. Fixed where the engine differed: an ARP request's target MAC is the broadcast address, an ICMP error's identifier and sequence number are 0 (RFC 792), and host unreachable from a router carries the packet as forwarded. Not taken: Java's lower-case broadcast address in a reply to 0.0.0.0, and the typo 'Exeeded'. RIP keeps its recordings: Java's staggered router start now gives a different startup on this machine. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
523 lines
23 KiB
Java
523 lines
23 KiB
Java
// A core scenario's network (packages/core/test/scenarios/*.scenario.json:
|
|
// nodes, links (wireless or not), devices, switches' and hosts' SSIDs,
|
|
// dhcpServers, dhcpClients, …) built in Java FILIUS the
|
|
// way the core reads it: a port's MAC is its `mac`, else the core's
|
|
// stableGeneratedMacAddress. Started as the action mode starts it (every
|
|
// system software's starten()); afterwards every frame each network card saw
|
|
// (FILIUS' own packet log, the Lauscher) and each host's addresses. Shared by
|
|
// DhcpRun and TcpRun.
|
|
|
|
import filius.gui.GUIContainer;
|
|
import filius.hardware.Kabel;
|
|
import filius.hardware.NetzwerkInterface;
|
|
import filius.hardware.Port;
|
|
import filius.hardware.knoten.Gateway;
|
|
import filius.hardware.knoten.InternetKnoten;
|
|
import filius.hardware.knoten.Knoten;
|
|
import filius.hardware.knoten.LokalerKnoten;
|
|
import filius.hardware.knoten.Notebook;
|
|
import filius.hardware.knoten.Rechner;
|
|
import filius.hardware.knoten.Switch;
|
|
import filius.hardware.knoten.Vermittlungsrechner;
|
|
import filius.rahmenprogramm.nachrichten.Lauscher;
|
|
import filius.software.dhcp.DHCPServer;
|
|
import filius.software.dns.DNSServer;
|
|
import filius.software.netzzugangsschicht.EthernetFrame;
|
|
import filius.software.system.Betriebssystem;
|
|
import filius.software.system.GatewayFirmware;
|
|
import filius.software.system.InternetKnotenBetriebssystem;
|
|
import filius.software.system.SwitchFirmware;
|
|
import filius.software.system.VermittlungsrechnerBetriebssystem;
|
|
import filius.software.transportschicht.Segment;
|
|
import filius.software.transportschicht.TcpSegment;
|
|
import filius.software.vermittlungsschicht.ArpPaket;
|
|
import filius.software.vermittlungsschicht.IcmpPaket;
|
|
import filius.software.vermittlungsschicht.IpPaket;
|
|
|
|
import java.io.FileOutputStream;
|
|
import java.io.OutputStreamWriter;
|
|
import java.io.Writer;
|
|
import java.lang.reflect.Field;
|
|
import java.nio.charset.StandardCharsets;
|
|
import java.nio.file.Files;
|
|
import java.nio.file.Paths;
|
|
import java.util.ArrayList;
|
|
import java.util.HashMap;
|
|
import java.util.LinkedHashMap;
|
|
import java.util.LinkedList;
|
|
import java.util.List;
|
|
import java.util.Map;
|
|
import java.util.TreeMap;
|
|
|
|
public class ScenarioNetwork {
|
|
final Map<String, Knoten> nodes = new LinkedHashMap<>();
|
|
/** Each card's MAC address, under its node's id (the first card) or `id#index` (the others). */
|
|
final Map<String, String> macByNode = new LinkedHashMap<>();
|
|
final List<String> servers = new ArrayList<>();
|
|
long start;
|
|
|
|
@SuppressWarnings("unchecked")
|
|
static ScenarioNetwork read(String path) throws Exception {
|
|
Map<String, Object> scenario = (Map<String, Object>) new JsonReader(
|
|
new String(Files.readAllBytes(Paths.get(path)), StandardCharsets.UTF_8)).value();
|
|
ScenarioNetwork network = new ScenarioNetwork();
|
|
network.build(scenario);
|
|
return network;
|
|
}
|
|
|
|
/** The scenario's seed. */
|
|
@SuppressWarnings("unchecked")
|
|
static long seed(String path) throws Exception {
|
|
Map<String, Object> scenario = (Map<String, Object>) new JsonReader(
|
|
new String(Files.readAllBytes(Paths.get(path)), StandardCharsets.UTF_8)).value();
|
|
return ((Number) scenario.get("seed")).longValue();
|
|
}
|
|
|
|
private void build(Map<String, Object> scenario) {
|
|
Map<String, Port> ports = new HashMap<>();
|
|
Map<String, NetzwerkInterface> nicsByPort = new HashMap<>();
|
|
for (Object each : list(scenario.get("nodes"))) {
|
|
Map<String, Object> node = map(each);
|
|
String id = (String) node.get("id");
|
|
String kind = (String) node.get("kind");
|
|
List<Object> nodePorts = list(node.get("ports"));
|
|
Knoten knoten;
|
|
switch (kind) {
|
|
case "pc":
|
|
knoten = new Rechner();
|
|
break;
|
|
case "notebook":
|
|
knoten = new Notebook();
|
|
break;
|
|
case "networkSwitch":
|
|
knoten = new Switch();
|
|
break;
|
|
case "router":
|
|
knoten = new Vermittlungsrechner();
|
|
((InternetKnoten) knoten).setzeAnzahlAnschluesse(nodePorts.size());
|
|
break;
|
|
case "gateway":
|
|
// Its first card is the WAN port, its second the LAN port.
|
|
knoten = new Gateway();
|
|
break;
|
|
default:
|
|
throw new IllegalArgumentException("node kind " + kind + " is not built by this tool");
|
|
}
|
|
knoten.setName(id);
|
|
if (knoten instanceof InternetKnoten) {
|
|
List<NetzwerkInterface> nics = ((InternetKnoten) knoten).getNetzwerkInterfaces();
|
|
for (int index = 0; index < nodePorts.size(); index++) {
|
|
Map<String, Object> port = map(nodePorts.get(index));
|
|
NetzwerkInterface nic = nics.get(index);
|
|
String mac = port.containsKey("mac") ? (String) port.get("mac") : stableMac((String) port.get("id"));
|
|
nic.setMac(mac);
|
|
ports.put((String) port.get("id"), nic.getPort());
|
|
nicsByPort.put((String) port.get("id"), nic);
|
|
macByNode.put(index == 0 ? id : id + "#" + index, mac);
|
|
}
|
|
} else {
|
|
LinkedList<Port> switchPorts = ((LokalerKnoten) knoten).getAnschluesse();
|
|
for (int index = 0; index < nodePorts.size(); index++) {
|
|
ports.put((String) map(nodePorts.get(index)).get("id"), switchPorts.get(index));
|
|
}
|
|
}
|
|
nodes.put(id, knoten);
|
|
}
|
|
for (Map.Entry<String, Object> entry : map(scenario.getOrDefault("devices", new HashMap<>())).entrySet()) {
|
|
Map<String, Object> device = map(entry.getValue());
|
|
NetzwerkInterface nic = ((InternetKnoten) nodes.get(entry.getKey())).getNetzwerkInterfaces().get(0);
|
|
nic.setIp((String) device.get("ip"));
|
|
nic.setSubnetzMaske((String) device.get("mask"));
|
|
nic.setGateway((String) device.getOrDefault("gateway", ""));
|
|
nic.setDns((String) device.getOrDefault("dns", ""));
|
|
}
|
|
// A router's other cards: `interfaces` names them by port.
|
|
for (Object each : list(scenario.getOrDefault("interfaces", new ArrayList<>()))) {
|
|
Map<String, Object> item = map(each);
|
|
NetzwerkInterface nic = nicsByPort.get(item.get("port"));
|
|
nic.setIp((String) item.get("ip"));
|
|
nic.setSubnetzMaske((String) item.get("mask"));
|
|
}
|
|
for (Object each : list(scenario.get("links"))) {
|
|
Map<String, Object> link = map(each);
|
|
Kabel cable = new Kabel();
|
|
cable.setAnschluesse(new Port[] { ports.get(link.get("sourcePort")), ports.get(link.get("targetPort")) });
|
|
cable.setWireless(Boolean.TRUE.equals(link.get("wireless")));
|
|
}
|
|
// A switch's SSID and learning time; a host's wireless card and the SSID
|
|
// it joins. The SSIDs only the editor reads, to lay a wireless cable.
|
|
for (Map.Entry<String, Object> entry : map(scenario.getOrDefault("switches", new HashMap<>())).entrySet()) {
|
|
Map<String, Object> config = map(entry.getValue());
|
|
SwitchFirmware firmware = (SwitchFirmware) nodes.get(entry.getKey()).getSystemSoftware();
|
|
firmware.setSSID((String) config.get("ssid"));
|
|
// How long the switch keeps a learned MAC address (Java's default 300 s).
|
|
if (config.containsKey("retention")) firmware.setRetentionTime(((Number) config.get("retention")).longValue());
|
|
}
|
|
for (Map.Entry<String, Object> entry : map(scenario.getOrDefault("wireless", new HashMap<>())).entrySet()) {
|
|
Betriebssystem os = (Betriebssystem) system(nodes.get(entry.getKey()));
|
|
os.nicWireless(true);
|
|
os.setSsid((String) map(entry.getValue()).get("ssid"));
|
|
}
|
|
for (Map.Entry<String, Object> entry : map(scenario.getOrDefault("dhcpServers", new HashMap<>())).entrySet()) {
|
|
Map<String, Object> config = map(entry.getValue());
|
|
DHCPServer server = system(nodes.get(entry.getKey())).getDHCPServer();
|
|
server.setUntergrenze((String) config.getOrDefault("lower", "0.0.0.0"));
|
|
server.setObergrenze((String) config.getOrDefault("upper", "0.0.0.0"));
|
|
server.setGatewayip((String) config.getOrDefault("gateway", "0.0.0.0"));
|
|
server.setDnsserverip((String) config.getOrDefault("dns", "0.0.0.0"));
|
|
server.setOwnSettings(Boolean.TRUE.equals(config.get("own")));
|
|
for (Object assignment : list(config.getOrDefault("staticAssignments", new ArrayList<>()))) {
|
|
server.addStaticAssignment((String) list(assignment).get(0), (String) list(assignment).get(1));
|
|
}
|
|
server.setAktiv(Boolean.TRUE.equals(config.get("active")));
|
|
servers.add(entry.getKey());
|
|
}
|
|
// A DNS server: installed, its records written to /dns/hosts (TTL 3600), switched on.
|
|
for (Map.Entry<String, Object> entry : map(scenario.getOrDefault("dnsServers", new HashMap<>())).entrySet()) {
|
|
Map<String, Object> config = map(entry.getValue());
|
|
InternetKnotenBetriebssystem os = system(nodes.get(entry.getKey()));
|
|
DNSServer dns = new DNSServer();
|
|
dns.setSystemSoftware(os);
|
|
os.getInstallierteAnwendungen().put("filius.software.dns.DNSServer", dns);
|
|
for (Object line : list(config.getOrDefault("records", new ArrayList<>()))) {
|
|
String[] parts = ((String) line).split(" ");
|
|
dns.hinzuRecord(parts[0], parts[1], parts[2]);
|
|
}
|
|
dns.setRecursiveResolutionEnabled(Boolean.TRUE.equals(config.get("recursive")));
|
|
dns.setAktiv(true);
|
|
servers.add(entry.getKey());
|
|
}
|
|
for (Object each : list(scenario.getOrDefault("dhcpClients", new ArrayList<>()))) {
|
|
system(nodes.get((String) each)).setDHCPKonfiguration(true);
|
|
}
|
|
// A home router's port forwarding: rows of protocol, public port, LAN address, LAN port.
|
|
for (Map.Entry<String, Object> entry : map(scenario.getOrDefault("portForwarding", new HashMap<>())).entrySet()) {
|
|
LinkedList<String[]> rows = new LinkedList<>();
|
|
for (Object row : list(entry.getValue())) rows.add(list(row).stream().map(String::valueOf).toArray(String[]::new));
|
|
((GatewayFirmware) system(nodes.get(entry.getKey()))).setStaticNAT(rows);
|
|
}
|
|
for (Object each : list(scenario.getOrDefault("rip", new ArrayList<>()))) {
|
|
((VermittlungsrechnerBetriebssystem) system(nodes.get((String) each))).setRipEnabled(true);
|
|
}
|
|
|
|
}
|
|
|
|
/** Starts the simulation; times in the log count from here. */
|
|
void start() throws InterruptedException {
|
|
// Switches and DHCP servers first: FILIUS' client waits for every active
|
|
// server to have started (waitUntilDhcpServerStarted, through the GUI's
|
|
// node list, which this tool has not got).
|
|
// FILIUS' window exists before a simulation starts; created lazily and
|
|
// unsynchronized, the first clients to ask for it would race.
|
|
GUIContainer.getGUIContainer();
|
|
start = System.currentTimeMillis();
|
|
for (Map.Entry<String, Knoten> entry : nodes.entrySet()) {
|
|
if (entry.getValue() instanceof Switch || servers.contains(entry.getKey())) entry.getValue().getSystemSoftware().starten();
|
|
}
|
|
Thread.sleep(200);
|
|
for (Map.Entry<String, Knoten> entry : nodes.entrySet()) {
|
|
if (!(entry.getValue() instanceof Switch || servers.contains(entry.getKey()))) entry.getValue().getSystemSoftware().starten();
|
|
}
|
|
}
|
|
|
|
InternetKnotenBetriebssystem system(String nodeId) {
|
|
return (InternetKnotenBetriebssystem) nodes.get(nodeId).getSystemSoftware();
|
|
}
|
|
|
|
/** Every frame each host's card saw, by node. */
|
|
void writeFrames(Json out) throws Exception {
|
|
Field field = Lauscher.class.getDeclaredField("datenEinheiten");
|
|
field.setAccessible(true);
|
|
@SuppressWarnings("unchecked")
|
|
Map<String, LinkedList<Object[]>> log = (Map<String, LinkedList<Object[]>>) field.get(Lauscher.getLauscher());
|
|
out.key("frames").open('{');
|
|
for (Map.Entry<String, String> entry : macByNode.entrySet()) {
|
|
out.key(entry.getKey()).open('[');
|
|
LinkedList<Object[]> frames = log.get(entry.getValue());
|
|
if (frames != null) {
|
|
synchronized (frames) {
|
|
for (Object[] stamped : frames) frame(out, ((Long) stamped[0]) - start, (EthernetFrame) stamped[1]);
|
|
}
|
|
}
|
|
out.close(']');
|
|
}
|
|
out.close('}');
|
|
writeRows(out, "rows", true);
|
|
writeRows(out, "layeredRows", false);
|
|
}
|
|
|
|
/**
|
|
* The packet capture's table of each card as FILIUS shows it
|
|
* (Lauscher.getDaten): with addresses and ports (`rows`, the data exchange
|
|
* window) or ports only (`layeredRows`, the layered view). Each row: number,
|
|
* source, destination, protocol, layer, comment — its time (the wall clock)
|
|
* left out.
|
|
*/
|
|
void writeRows(Json out, String key, boolean withAddresses) {
|
|
out.key(key).open('{');
|
|
for (Map.Entry<String, String> entry : macByNode.entrySet()) {
|
|
out.key(entry.getKey()).open('[');
|
|
for (Object[] row : Lauscher.getLauscher().getDaten(entry.getValue(), withAddresses, 1)) {
|
|
out.open('[');
|
|
for (int column = 0; column < row.length; column++) {
|
|
if (column == 1) continue;
|
|
out.string(row[column] == null ? null : String.valueOf(row[column]));
|
|
}
|
|
out.close(']');
|
|
}
|
|
out.close(']');
|
|
}
|
|
out.close('}');
|
|
}
|
|
|
|
/** Each host's address, mask, gateway and DNS server. */
|
|
void writeDevices(Json out) {
|
|
out.key("devices").open('{');
|
|
for (Map.Entry<String, Knoten> entry : nodes.entrySet()) {
|
|
if (!(entry.getValue() instanceof InternetKnoten)) continue;
|
|
NetzwerkInterface nic = ((InternetKnoten) entry.getValue()).getNetzwerkInterfaces().get(0);
|
|
out.key(entry.getKey()).open('[').string(nic.getIp()).string(nic.getSubnetzMaske()).string(nic.getGateway())
|
|
.string(nic.getDns()).close(']');
|
|
}
|
|
out.close('}');
|
|
}
|
|
|
|
void stop() {
|
|
for (Knoten knoten : nodes.values()) {
|
|
try {
|
|
knoten.getSystemSoftware().beenden();
|
|
} catch (Exception e) {}
|
|
}
|
|
}
|
|
|
|
static void write(String path, Json out) throws Exception {
|
|
try (Writer writer = new OutputStreamWriter(new FileOutputStream(path), StandardCharsets.UTF_8)) {
|
|
writer.write(out.toString());
|
|
writer.write('\n');
|
|
}
|
|
}
|
|
|
|
private static void frame(Json out, long time, EthernetFrame frame) {
|
|
out.open('{').key("time").number(time).key("source").string(frame.getQuellMacAdresse()).key("destination")
|
|
.string(frame.getZielMacAdresse());
|
|
Object data = frame.getDaten();
|
|
if (data instanceof ArpPaket) {
|
|
ArpPaket arp = (ArpPaket) data;
|
|
out.key("arp").number(arp.getOperation()).key("senderIp").string(arp.getSenderIP()).key("targetIp")
|
|
.string(arp.getTargetIP());
|
|
} else if (data instanceof IcmpPaket) {
|
|
IcmpPaket icmp = (IcmpPaket) data;
|
|
out.key("icmp").number(icmp.getIcmpType()).key("icmpCode").number(icmp.getIcmpCode()).key("sourceIp")
|
|
.string(icmp.getSender()).key("destinationIp").string(icmp.getEmpfaenger()).key("sequence")
|
|
.number(icmp.getSeqNr());
|
|
} else if (data instanceof IpPaket) {
|
|
IpPaket ip = (IpPaket) data;
|
|
Segment segment = ip.getSegment();
|
|
out.key("sourceIp").string(ip.getSender()).key("destinationIp").string(ip.getEmpfaenger()).key("protocol")
|
|
.number(ip.getProtocol()).key("sourcePort").number(segment.getQuellPort()).key("destinationPort")
|
|
.number(segment.getZielPort()).key("payload").string(segment.getDaten());
|
|
if (segment instanceof TcpSegment) {
|
|
TcpSegment tcp = (TcpSegment) segment;
|
|
String flags = (tcp.isSyn() ? "S" : "") + (tcp.isAck() ? "A" : "") + (tcp.isFin() ? "F" : "")
|
|
+ (tcp.isPush() ? "P" : "") + (tcp.isRst() ? "R" : "");
|
|
out.key("tcpFlags").string(flags).key("sequence").number(tcp.getSeqNummer()).key("acknowledgement")
|
|
.number(tcp.getAckNummer());
|
|
}
|
|
}
|
|
out.close('}');
|
|
}
|
|
|
|
private static InternetKnotenBetriebssystem system(Knoten knoten) {
|
|
return (InternetKnotenBetriebssystem) knoten.getSystemSoftware();
|
|
}
|
|
|
|
/** The core's stableGeneratedMacAddress (packages/core/src/model/node.ts). */
|
|
static String stableMac(String portId) {
|
|
String compact = portId.toUpperCase().replace("-", "");
|
|
String suffix = compact.substring(compact.length() - 10);
|
|
StringBuilder mac = new StringBuilder("02");
|
|
for (int offset = 0; offset < suffix.length(); offset += 2) mac.append(':').append(suffix, offset, offset + 2);
|
|
return mac.toString();
|
|
}
|
|
|
|
@SuppressWarnings("unchecked")
|
|
private static Map<String, Object> map(Object value) {
|
|
return (Map<String, Object>) value;
|
|
}
|
|
|
|
@SuppressWarnings("unchecked")
|
|
private static List<Object> list(Object value) {
|
|
return (List<Object>) value;
|
|
}
|
|
|
|
/** Just enough JSON for a scenario: objects, arrays, strings, numbers, booleans, null. */
|
|
static final class JsonReader {
|
|
private final String text;
|
|
private int position;
|
|
|
|
JsonReader(String text) {
|
|
this.text = text;
|
|
}
|
|
|
|
Object value() {
|
|
skip();
|
|
char c = text.charAt(position);
|
|
if (c == '{') {
|
|
Map<String, Object> result = new TreeMap<>();
|
|
position++;
|
|
skip();
|
|
if (text.charAt(position) == '}') {
|
|
position++;
|
|
return result;
|
|
}
|
|
for (;;) {
|
|
skip();
|
|
String key = (String) value();
|
|
skip();
|
|
expect(':');
|
|
result.put(key, value());
|
|
skip();
|
|
if (text.charAt(position++) == '}') return result;
|
|
}
|
|
}
|
|
if (c == '[') {
|
|
List<Object> result = new ArrayList<>();
|
|
position++;
|
|
skip();
|
|
if (text.charAt(position) == ']') {
|
|
position++;
|
|
return result;
|
|
}
|
|
for (;;) {
|
|
result.add(value());
|
|
skip();
|
|
if (text.charAt(position++) == ']') return result;
|
|
}
|
|
}
|
|
if (c == '"') {
|
|
StringBuilder result = new StringBuilder();
|
|
position++;
|
|
for (char each = text.charAt(position++); each != '"'; each = text.charAt(position++)) {
|
|
if (each == '\\') {
|
|
char escaped = text.charAt(position++);
|
|
switch (escaped) {
|
|
case 'n':
|
|
result.append('\n');
|
|
break;
|
|
case 't':
|
|
result.append('\t');
|
|
break;
|
|
case 'u':
|
|
result.append((char) Integer.parseInt(text.substring(position, position + 4), 16));
|
|
position += 4;
|
|
break;
|
|
default:
|
|
result.append(escaped);
|
|
}
|
|
} else {
|
|
result.append(each);
|
|
}
|
|
}
|
|
return result.toString();
|
|
}
|
|
if (text.startsWith("true", position)) {
|
|
position += 4;
|
|
return Boolean.TRUE;
|
|
}
|
|
if (text.startsWith("false", position)) {
|
|
position += 5;
|
|
return Boolean.FALSE;
|
|
}
|
|
if (text.startsWith("null", position)) {
|
|
position += 4;
|
|
return null;
|
|
}
|
|
int begin = position;
|
|
while (position < text.length() && "+-.eE0123456789".indexOf(text.charAt(position)) >= 0) position++;
|
|
return Double.parseDouble(text.substring(begin, position));
|
|
}
|
|
|
|
private void skip() {
|
|
while (position < text.length() && Character.isWhitespace(text.charAt(position))) position++;
|
|
}
|
|
|
|
private void expect(char c) {
|
|
if (text.charAt(position++) != c) throw new IllegalArgumentException("expected " + c + " at " + (position - 1));
|
|
}
|
|
}
|
|
|
|
/** A JSON writer with commas and indentation. */
|
|
static final class Json {
|
|
private final StringBuilder text = new StringBuilder();
|
|
private final LinkedList<Boolean> first = new LinkedList<>();
|
|
private boolean afterKey;
|
|
|
|
private void separate() {
|
|
if (afterKey) {
|
|
afterKey = false;
|
|
return;
|
|
}
|
|
if (first.isEmpty()) return;
|
|
if (!first.peek()) text.append(',');
|
|
first.pop();
|
|
first.push(false);
|
|
text.append('\n');
|
|
for (int i = 0; i < first.size(); i++) text.append(" ");
|
|
}
|
|
|
|
Json open(char bracket) {
|
|
separate();
|
|
text.append(bracket);
|
|
first.push(true);
|
|
return this;
|
|
}
|
|
|
|
Json close(char bracket) {
|
|
boolean empty = first.pop();
|
|
if (!empty) {
|
|
text.append('\n');
|
|
for (int i = 0; i < first.size(); i++) text.append(" ");
|
|
}
|
|
text.append(bracket);
|
|
return this;
|
|
}
|
|
|
|
Json key(String key) {
|
|
separate();
|
|
quote(key);
|
|
text.append(": ");
|
|
afterKey = true;
|
|
return this;
|
|
}
|
|
|
|
Json string(String value) {
|
|
separate();
|
|
if (value == null) text.append("null");
|
|
else quote(value);
|
|
return this;
|
|
}
|
|
|
|
Json number(long value) {
|
|
separate();
|
|
text.append(value);
|
|
return this;
|
|
}
|
|
|
|
private void quote(String value) {
|
|
text.append('"');
|
|
for (char c : value.toCharArray()) {
|
|
if (c == '"' || c == '\\') text.append('\\').append(c);
|
|
else if (c == '\n') text.append("\\n");
|
|
else if (c < 0x20) text.append(String.format("\\u%04x", (int) c));
|
|
else text.append(c);
|
|
}
|
|
text.append('"');
|
|
}
|
|
|
|
@Override
|
|
public String toString() {
|
|
return text.toString();
|
|
}
|
|
}
|
|
}
|