[Feature sync] fix handling arbitrary bytes in TXT value
RFC 6763 defines that TXT value can accept both utf-8 string and binary data. Current implementation will always cast the TXT value to a utf-8 string and will cause data lose when there are non-utf-8 chars in the TXT value. This commit fixes this by having the browser passing the TXT values back as byte[]. Also fixed the TXT key&value parsing issues per RFC 6763 section 6.5: accept cases of no '=' and reject empty key. Bug: 254155029 Test: atest FrameworksNetTests CtsNetTestCases Change-Id: I4b755e60ad6e59db19faa41556dd214993d73896
This commit is contained in:
@@ -16,8 +16,11 @@
|
|||||||
|
|
||||||
package com.android.server.connectivity.mdns;
|
package com.android.server.connectivity.mdns;
|
||||||
|
|
||||||
|
import android.annotation.Nullable;
|
||||||
import android.util.SparseArray;
|
import android.util.SparseArray;
|
||||||
|
|
||||||
|
import com.android.server.connectivity.mdns.MdnsServiceInfo.TextEntry;
|
||||||
|
|
||||||
import java.io.EOFException;
|
import java.io.EOFException;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.net.DatagramPacket;
|
import java.net.DatagramPacket;
|
||||||
@@ -195,6 +198,16 @@ public class MdnsPacketReader {
|
|||||||
return val;
|
return val;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
public TextEntry readTextEntry() throws EOFException {
|
||||||
|
int len = readUInt8();
|
||||||
|
checkRemaining(len);
|
||||||
|
byte[] bytes = new byte[len];
|
||||||
|
System.arraycopy(buf, pos, bytes, 0, bytes.length);
|
||||||
|
pos += len;
|
||||||
|
return TextEntry.fromBytes(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reads a specific number of bytes.
|
* Reads a specific number of bytes.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -16,6 +16,8 @@
|
|||||||
|
|
||||||
package com.android.server.connectivity.mdns;
|
package com.android.server.connectivity.mdns;
|
||||||
|
|
||||||
|
import com.android.server.connectivity.mdns.MdnsServiceInfo.TextEntry;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.net.DatagramPacket;
|
import java.net.DatagramPacket;
|
||||||
import java.net.SocketAddress;
|
import java.net.SocketAddress;
|
||||||
@@ -147,6 +149,12 @@ public class MdnsPacketWriter {
|
|||||||
writeBytes(utf8);
|
writeBytes(utf8);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void writeTextEntry(TextEntry textEntry) throws IOException {
|
||||||
|
byte[] bytes = textEntry.toBytes();
|
||||||
|
writeUInt8(bytes.length);
|
||||||
|
writeBytes(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Writes a series of labels. Uses name compression.
|
* Writes a series of labels. Uses name compression.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -17,11 +17,16 @@
|
|||||||
package com.android.server.connectivity.mdns;
|
package com.android.server.connectivity.mdns;
|
||||||
|
|
||||||
import android.annotation.NonNull;
|
import android.annotation.NonNull;
|
||||||
|
import android.annotation.Nullable;
|
||||||
import android.os.Parcel;
|
import android.os.Parcel;
|
||||||
import android.os.Parcelable;
|
import android.os.Parcelable;
|
||||||
import android.text.TextUtils;
|
import android.text.TextUtils;
|
||||||
|
|
||||||
|
import com.android.net.module.util.ByteUtils;
|
||||||
|
|
||||||
|
import java.nio.charset.Charset;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.Arrays;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -34,6 +39,8 @@ import java.util.Map;
|
|||||||
* @hide
|
* @hide
|
||||||
*/
|
*/
|
||||||
public class MdnsServiceInfo implements Parcelable {
|
public class MdnsServiceInfo implements Parcelable {
|
||||||
|
private static final Charset US_ASCII = Charset.forName("us-ascii");
|
||||||
|
private static final Charset UTF_8 = Charset.forName("utf-8");
|
||||||
|
|
||||||
/** @hide */
|
/** @hide */
|
||||||
public static final Parcelable.Creator<MdnsServiceInfo> CREATOR =
|
public static final Parcelable.Creator<MdnsServiceInfo> CREATOR =
|
||||||
@@ -49,7 +56,8 @@ public class MdnsServiceInfo implements Parcelable {
|
|||||||
source.readInt(),
|
source.readInt(),
|
||||||
source.readString(),
|
source.readString(),
|
||||||
source.readString(),
|
source.readString(),
|
||||||
source.createStringArrayList());
|
source.createStringArrayList(),
|
||||||
|
source.createTypedArrayList(TextEntry.CREATOR));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -65,8 +73,33 @@ public class MdnsServiceInfo implements Parcelable {
|
|||||||
private final int port;
|
private final int port;
|
||||||
private final String ipv4Address;
|
private final String ipv4Address;
|
||||||
private final String ipv6Address;
|
private final String ipv6Address;
|
||||||
private final Map<String, String> attributes = new HashMap<>();
|
final List<String> textStrings;
|
||||||
List<String> textStrings;
|
@Nullable
|
||||||
|
final List<TextEntry> textEntries;
|
||||||
|
|
||||||
|
private final Map<String, byte[]> attributes;
|
||||||
|
|
||||||
|
/** Constructs a {@link MdnsServiceInfo} object with default values. */
|
||||||
|
public MdnsServiceInfo(
|
||||||
|
String serviceInstanceName,
|
||||||
|
String[] serviceType,
|
||||||
|
List<String> subtypes,
|
||||||
|
String[] hostName,
|
||||||
|
int port,
|
||||||
|
String ipv4Address,
|
||||||
|
String ipv6Address,
|
||||||
|
List<String> textStrings) {
|
||||||
|
this(
|
||||||
|
serviceInstanceName,
|
||||||
|
serviceType,
|
||||||
|
subtypes,
|
||||||
|
hostName,
|
||||||
|
port,
|
||||||
|
ipv4Address,
|
||||||
|
ipv6Address,
|
||||||
|
textStrings,
|
||||||
|
/* textEntries= */ null);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Constructs a {@link MdnsServiceInfo} object with default values.
|
* Constructs a {@link MdnsServiceInfo} object with default values.
|
||||||
@@ -81,7 +114,8 @@ public class MdnsServiceInfo implements Parcelable {
|
|||||||
int port,
|
int port,
|
||||||
String ipv4Address,
|
String ipv4Address,
|
||||||
String ipv6Address,
|
String ipv6Address,
|
||||||
List<String> textStrings) {
|
List<String> textStrings,
|
||||||
|
@Nullable List<TextEntry> textEntries) {
|
||||||
this.serviceInstanceName = serviceInstanceName;
|
this.serviceInstanceName = serviceInstanceName;
|
||||||
this.serviceType = serviceType;
|
this.serviceType = serviceType;
|
||||||
this.subtypes = new ArrayList<>();
|
this.subtypes = new ArrayList<>();
|
||||||
@@ -92,16 +126,40 @@ public class MdnsServiceInfo implements Parcelable {
|
|||||||
this.port = port;
|
this.port = port;
|
||||||
this.ipv4Address = ipv4Address;
|
this.ipv4Address = ipv4Address;
|
||||||
this.ipv6Address = ipv6Address;
|
this.ipv6Address = ipv6Address;
|
||||||
|
this.textStrings = new ArrayList<>();
|
||||||
if (textStrings != null) {
|
if (textStrings != null) {
|
||||||
for (String text : textStrings) {
|
this.textStrings.addAll(textStrings);
|
||||||
int pos = text.indexOf('=');
|
|
||||||
if (pos < 1) {
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
attributes.put(text.substring(0, pos).toLowerCase(Locale.ENGLISH),
|
this.textEntries = (textEntries == null) ? null : new ArrayList<>(textEntries);
|
||||||
text.substring(++pos));
|
|
||||||
|
// The module side sends both {@code textStrings} and {@code textEntries} for backward
|
||||||
|
// compatibility. We should prefer only {@code textEntries} if it's not null.
|
||||||
|
List<TextEntry> entries =
|
||||||
|
(this.textEntries != null) ? this.textEntries : parseTextStrings(this.textStrings);
|
||||||
|
Map<String, byte[]> attributes = new HashMap<>(entries.size());
|
||||||
|
for (TextEntry entry : entries) {
|
||||||
|
String key = entry.getKey().toLowerCase(Locale.ENGLISH);
|
||||||
|
|
||||||
|
// Per https://datatracker.ietf.org/doc/html/rfc6763#section-6.4, only the first entry
|
||||||
|
// of the same key should be accepted:
|
||||||
|
// If a client receives a TXT record containing the same key more than once, then the
|
||||||
|
// client MUST silently ignore all but the first occurrence of that attribute.
|
||||||
|
if (!attributes.containsKey(key)) {
|
||||||
|
attributes.put(key, entry.getValue());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
this.attributes = Collections.unmodifiableMap(attributes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<TextEntry> parseTextStrings(List<String> textStrings) {
|
||||||
|
List<TextEntry> list = new ArrayList(textStrings.size());
|
||||||
|
for (String textString : textStrings) {
|
||||||
|
TextEntry entry = TextEntry.fromString(textString);
|
||||||
|
if (entry != null) {
|
||||||
|
list.add(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Collections.unmodifiableList(list);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return the name of this service instance. */
|
/** @return the name of this service instance. */
|
||||||
@@ -148,16 +206,35 @@ public class MdnsServiceInfo implements Parcelable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return the attribute value for {@code key}.
|
* Returns attribute value for {@code key} as a UTF-8 string. It's the caller who must make sure
|
||||||
* @return {@code null} if no attribute value exists for {@code key}.
|
* that the value of {@code key} is indeed a UTF-8 string. {@code null} will be returned if no
|
||||||
|
* attribute value exists for {@code key}.
|
||||||
*/
|
*/
|
||||||
|
@Nullable
|
||||||
public String getAttributeByKey(@NonNull String key) {
|
public String getAttributeByKey(@NonNull String key) {
|
||||||
|
byte[] value = getAttributeAsBytes(key);
|
||||||
|
if (value == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return new String(value, UTF_8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the attribute value for {@code key} as a byte array. {@code null} will be returned if
|
||||||
|
* no attribute value exists for {@code key}.
|
||||||
|
*/
|
||||||
|
@Nullable
|
||||||
|
public byte[] getAttributeAsBytes(@NonNull String key) {
|
||||||
return attributes.get(key.toLowerCase(Locale.ENGLISH));
|
return attributes.get(key.toLowerCase(Locale.ENGLISH));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return an immutable map of all attributes. */
|
/** @return an immutable map of all attributes. */
|
||||||
public Map<String, String> getAttributes() {
|
public Map<String, String> getAttributes() {
|
||||||
return Collections.unmodifiableMap(attributes);
|
Map<String, String> map = new HashMap<>(attributes.size());
|
||||||
|
for (Map.Entry<String, byte[]> kv : attributes.entrySet()) {
|
||||||
|
map.put(kv.getKey(), new String(kv.getValue(), UTF_8));
|
||||||
|
}
|
||||||
|
return Collections.unmodifiableMap(map);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -167,14 +244,6 @@ public class MdnsServiceInfo implements Parcelable {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void writeToParcel(Parcel out, int flags) {
|
public void writeToParcel(Parcel out, int flags) {
|
||||||
if (textStrings == null) {
|
|
||||||
// Lazily initialize the parcelable field mTextStrings.
|
|
||||||
textStrings = new ArrayList<>(attributes.size());
|
|
||||||
for (Map.Entry<String, String> kv : attributes.entrySet()) {
|
|
||||||
textStrings.add(String.format(Locale.ROOT, "%s=%s", kv.getKey(), kv.getValue()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
out.writeString(serviceInstanceName);
|
out.writeString(serviceInstanceName);
|
||||||
out.writeStringArray(serviceType);
|
out.writeStringArray(serviceType);
|
||||||
out.writeStringList(subtypes);
|
out.writeStringList(subtypes);
|
||||||
@@ -183,6 +252,7 @@ public class MdnsServiceInfo implements Parcelable {
|
|||||||
out.writeString(ipv4Address);
|
out.writeString(ipv4Address);
|
||||||
out.writeString(ipv6Address);
|
out.writeString(ipv6Address);
|
||||||
out.writeStringList(textStrings);
|
out.writeStringList(textStrings);
|
||||||
|
out.writeTypedList(textEntries);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -195,4 +265,114 @@ public class MdnsServiceInfo implements Parcelable {
|
|||||||
ipv4Address,
|
ipv4Address,
|
||||||
port);
|
port);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/** Represents a DNS TXT key-value pair defined by RFC 6763. */
|
||||||
|
public static final class TextEntry implements Parcelable {
|
||||||
|
public static final Parcelable.Creator<TextEntry> CREATOR =
|
||||||
|
new Parcelable.Creator<TextEntry>() {
|
||||||
|
@Override
|
||||||
|
public TextEntry createFromParcel(Parcel source) {
|
||||||
|
return new TextEntry(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public TextEntry[] newArray(int size) {
|
||||||
|
return new TextEntry[size];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private final String key;
|
||||||
|
private final byte[] value;
|
||||||
|
|
||||||
|
/** Creates a new {@link TextEntry} instance from a '=' separated string. */
|
||||||
|
@Nullable
|
||||||
|
public static TextEntry fromString(String textString) {
|
||||||
|
return fromBytes(textString.getBytes(UTF_8));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Creates a new {@link TextEntry} instance from a '=' separated byte array. */
|
||||||
|
@Nullable
|
||||||
|
public static TextEntry fromBytes(byte[] textBytes) {
|
||||||
|
int delimitPos = ByteUtils.indexOf(textBytes, (byte) '=');
|
||||||
|
|
||||||
|
// Per https://datatracker.ietf.org/doc/html/rfc6763#section-6.4:
|
||||||
|
// 1. The key MUST be at least one character. DNS-SD TXT record strings
|
||||||
|
// beginning with an '=' character (i.e., the key is missing) MUST be
|
||||||
|
// silently ignored.
|
||||||
|
// 2. If there is no '=' in a DNS-SD TXT record string, then it is a
|
||||||
|
// boolean attribute, simply identified as being present, with no value.
|
||||||
|
if (delimitPos < 0) {
|
||||||
|
return new TextEntry(new String(textBytes, US_ASCII), "");
|
||||||
|
} else if (delimitPos == 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return new TextEntry(
|
||||||
|
new String(Arrays.copyOf(textBytes, delimitPos), US_ASCII),
|
||||||
|
Arrays.copyOfRange(textBytes, delimitPos + 1, textBytes.length));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Creates a new {@link TextEntry} with given key and value of a UTF-8 string. */
|
||||||
|
public TextEntry(String key, String value) {
|
||||||
|
this(key, value.getBytes(UTF_8));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Creates a new {@link TextEntry} with given key and value of a byte array. */
|
||||||
|
public TextEntry(String key, byte[] value) {
|
||||||
|
this.key = key;
|
||||||
|
this.value = value.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
private TextEntry(Parcel in) {
|
||||||
|
key = in.readString();
|
||||||
|
value = in.createByteArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getKey() {
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
public byte[] getValue() {
|
||||||
|
return value.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Converts this {@link TextEntry} instance to '=' separated byte array. */
|
||||||
|
public byte[] toBytes() {
|
||||||
|
return ByteUtils.concat(key.getBytes(US_ASCII), new byte[]{'='}, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Converts this {@link TextEntry} instance to '=' separated string. */
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return key + "=" + new String(value, UTF_8);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean equals(@Nullable Object other) {
|
||||||
|
if (this == other) {
|
||||||
|
return true;
|
||||||
|
} else if (!(other instanceof TextEntry)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
TextEntry otherEntry = (TextEntry) other;
|
||||||
|
|
||||||
|
return key.equals(otherEntry.key) && Arrays.equals(value, otherEntry.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int hashCode() {
|
||||||
|
return 31 * key.hashCode() + Arrays.hashCode(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int describeContents() {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void writeToParcel(Parcel out, int flags) {
|
||||||
|
out.writeString(key);
|
||||||
|
out.writeByteArray(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -110,7 +110,8 @@ public class MdnsServiceTypeClient {
|
|||||||
port,
|
port,
|
||||||
ipv4Address,
|
ipv4Address,
|
||||||
ipv6Address,
|
ipv6Address,
|
||||||
response.getTextRecord().getStrings());
|
response.getTextRecord().getStrings(),
|
||||||
|
response.getTextRecord().getEntries());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
package com.android.server.connectivity.mdns;
|
package com.android.server.connectivity.mdns;
|
||||||
|
|
||||||
import com.android.internal.annotations.VisibleForTesting;
|
import com.android.internal.annotations.VisibleForTesting;
|
||||||
|
import com.android.server.connectivity.mdns.MdnsServiceInfo.TextEntry;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
@@ -24,12 +25,12 @@ import java.util.Collections;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
|
||||||
/** An mDNS "TXT" record, which contains a list of text strings. */
|
/** An mDNS "TXT" record, which contains a list of {@link TextEntry}. */
|
||||||
// TODO(b/242631897): Resolve nullness suppression.
|
// TODO(b/242631897): Resolve nullness suppression.
|
||||||
@SuppressWarnings("nullness")
|
@SuppressWarnings("nullness")
|
||||||
@VisibleForTesting
|
@VisibleForTesting
|
||||||
public class MdnsTextRecord extends MdnsRecord {
|
public class MdnsTextRecord extends MdnsRecord {
|
||||||
private List<String> strings;
|
private List<TextEntry> entries;
|
||||||
|
|
||||||
public MdnsTextRecord(String[] name, MdnsPacketReader reader) throws IOException {
|
public MdnsTextRecord(String[] name, MdnsPacketReader reader) throws IOException {
|
||||||
super(name, TYPE_TXT, reader);
|
super(name, TYPE_TXT, reader);
|
||||||
@@ -37,22 +38,34 @@ public class MdnsTextRecord extends MdnsRecord {
|
|||||||
|
|
||||||
/** Returns the list of strings. */
|
/** Returns the list of strings. */
|
||||||
public List<String> getStrings() {
|
public List<String> getStrings() {
|
||||||
return Collections.unmodifiableList(strings);
|
final List<String> list = new ArrayList<>(entries.size());
|
||||||
|
for (TextEntry entry : entries) {
|
||||||
|
list.add(entry.toString());
|
||||||
|
}
|
||||||
|
return Collections.unmodifiableList(list);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the list of TXT key-value pairs. */
|
||||||
|
public List<TextEntry> getEntries() {
|
||||||
|
return Collections.unmodifiableList(entries);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void readData(MdnsPacketReader reader) throws IOException {
|
protected void readData(MdnsPacketReader reader) throws IOException {
|
||||||
strings = new ArrayList<>();
|
entries = new ArrayList<>();
|
||||||
while (reader.getRemaining() > 0) {
|
while (reader.getRemaining() > 0) {
|
||||||
strings.add(reader.readString());
|
TextEntry entry = reader.readTextEntry();
|
||||||
|
if (entry != null) {
|
||||||
|
entries.add(entry);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void writeData(MdnsPacketWriter writer) throws IOException {
|
protected void writeData(MdnsPacketWriter writer) throws IOException {
|
||||||
if (strings != null) {
|
if (entries != null) {
|
||||||
for (String string : strings) {
|
for (TextEntry entry : entries) {
|
||||||
writer.writeString(string);
|
writer.writeTextEntry(entry);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -61,9 +74,9 @@ public class MdnsTextRecord extends MdnsRecord {
|
|||||||
public String toString() {
|
public String toString() {
|
||||||
StringBuilder sb = new StringBuilder();
|
StringBuilder sb = new StringBuilder();
|
||||||
sb.append("TXT: {");
|
sb.append("TXT: {");
|
||||||
if (strings != null) {
|
if (entries != null) {
|
||||||
for (String string : strings) {
|
for (TextEntry entry : entries) {
|
||||||
sb.append(' ').append(string);
|
sb.append(' ').append(entry);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
sb.append("}");
|
sb.append("}");
|
||||||
@@ -73,7 +86,7 @@ public class MdnsTextRecord extends MdnsRecord {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public int hashCode() {
|
public int hashCode() {
|
||||||
return (super.hashCode() * 31) + Objects.hash(strings);
|
return (super.hashCode() * 31) + Objects.hash(entries);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -85,6 +98,6 @@ public class MdnsTextRecord extends MdnsRecord {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return super.equals(other) && Objects.equals(strings, ((MdnsTextRecord) other).strings);
|
return super.equals(other) && Objects.equals(entries, ((MdnsTextRecord) other).entries);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -22,16 +22,19 @@ import static org.junit.Assert.assertEquals;
|
|||||||
import static org.junit.Assert.assertFalse;
|
import static org.junit.Assert.assertFalse;
|
||||||
import static org.junit.Assert.assertNotNull;
|
import static org.junit.Assert.assertNotNull;
|
||||||
import static org.junit.Assert.assertNull;
|
import static org.junit.Assert.assertNull;
|
||||||
|
import static org.junit.Assert.assertThrows;
|
||||||
|
|
||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
|
|
||||||
import com.android.net.module.util.HexDump;
|
import com.android.net.module.util.HexDump;
|
||||||
|
import com.android.server.connectivity.mdns.MdnsServiceInfo.TextEntry;
|
||||||
import com.android.testutils.DevSdkIgnoreRule;
|
import com.android.testutils.DevSdkIgnoreRule;
|
||||||
import com.android.testutils.DevSdkIgnoreRunner;
|
import com.android.testutils.DevSdkIgnoreRunner;
|
||||||
|
|
||||||
import org.junit.Test;
|
import org.junit.Test;
|
||||||
import org.junit.runner.RunWith;
|
import org.junit.runner.RunWith;
|
||||||
|
|
||||||
|
import java.io.EOFException;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.net.DatagramPacket;
|
import java.net.DatagramPacket;
|
||||||
import java.net.Inet4Address;
|
import java.net.Inet4Address;
|
||||||
@@ -309,6 +312,14 @@ public class MdnsRecordTests {
|
|||||||
assertEquals("b=1234567890", strings.get(1));
|
assertEquals("b=1234567890", strings.get(1));
|
||||||
assertEquals("xyz=!@#$", strings.get(2));
|
assertEquals("xyz=!@#$", strings.get(2));
|
||||||
|
|
||||||
|
List<TextEntry> entries = record.getEntries();
|
||||||
|
assertNotNull(entries);
|
||||||
|
assertEquals(3, entries.size());
|
||||||
|
|
||||||
|
assertEquals(new TextEntry("a", "hello there"), entries.get(0));
|
||||||
|
assertEquals(new TextEntry("b", "1234567890"), entries.get(1));
|
||||||
|
assertEquals(new TextEntry("xyz", "!@#$"), entries.get(2));
|
||||||
|
|
||||||
// Encode
|
// Encode
|
||||||
MdnsPacketWriter writer = new MdnsPacketWriter(MAX_PACKET_SIZE);
|
MdnsPacketWriter writer = new MdnsPacketWriter(MAX_PACKET_SIZE);
|
||||||
record.write(writer, record.getReceiptTime());
|
record.write(writer, record.getReceiptTime());
|
||||||
@@ -321,4 +332,48 @@ public class MdnsRecordTests {
|
|||||||
|
|
||||||
assertEquals(dataInText, dataOutText);
|
assertEquals(dataInText, dataOutText);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void textRecord_recordDoesNotHaveDataOfGivenLength_throwsEOFException()
|
||||||
|
throws Exception {
|
||||||
|
final byte[] dataIn = HexDump.hexStringToByteArray(
|
||||||
|
"0474657374000010"
|
||||||
|
+ "000100001194000D"
|
||||||
|
+ "0D613D68656C6C6F" //The TXT entry starts with length of 13, but only 12
|
||||||
|
+ "2074686572"); // characters are following it.
|
||||||
|
DatagramPacket packet = new DatagramPacket(dataIn, dataIn.length);
|
||||||
|
MdnsPacketReader reader = new MdnsPacketReader(packet);
|
||||||
|
String[] name = reader.readLabels();
|
||||||
|
MdnsRecord.labelsToString(name);
|
||||||
|
reader.readUInt16();
|
||||||
|
|
||||||
|
assertThrows(EOFException.class, () -> new MdnsTextRecord(name, reader));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void textRecord_entriesIncludeNonUtf8Bytes_returnsTheSameUtf8Bytes() throws Exception {
|
||||||
|
final byte[] dataIn = HexDump.hexStringToByteArray(
|
||||||
|
"0474657374000010"
|
||||||
|
+ "0001000011940024"
|
||||||
|
+ "0D613D68656C6C6F"
|
||||||
|
+ "2074686572650C62"
|
||||||
|
+ "3D31323334353637"
|
||||||
|
+ "3839300878797A3D"
|
||||||
|
+ "FFEFDFCF");
|
||||||
|
DatagramPacket packet = new DatagramPacket(dataIn, dataIn.length);
|
||||||
|
MdnsPacketReader reader = new MdnsPacketReader(packet);
|
||||||
|
String[] name = reader.readLabels();
|
||||||
|
MdnsRecord.labelsToString(name);
|
||||||
|
reader.readUInt16();
|
||||||
|
|
||||||
|
MdnsTextRecord record = new MdnsTextRecord(name, reader);
|
||||||
|
|
||||||
|
List<TextEntry> entries = record.getEntries();
|
||||||
|
assertNotNull(entries);
|
||||||
|
assertEquals(3, entries.size());
|
||||||
|
assertEquals(new TextEntry("a", "hello there"), entries.get(0));
|
||||||
|
assertEquals(new TextEntry("b", "1234567890"), entries.get(1));
|
||||||
|
assertEquals(new TextEntry("xyz", HexDump.hexStringToByteArray("FFEFDFCF")),
|
||||||
|
entries.get(2));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,239 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2022 The Android Open Source Project
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package com.android.server.connectivity.mdns;
|
||||||
|
|
||||||
|
import static com.android.testutils.DevSdkIgnoreRuleKt.SC_V2;
|
||||||
|
|
||||||
|
import static org.junit.Assert.assertArrayEquals;
|
||||||
|
import static org.junit.Assert.assertEquals;
|
||||||
|
import static org.junit.Assert.assertNull;
|
||||||
|
import static org.junit.Assert.assertTrue;
|
||||||
|
|
||||||
|
import android.os.Parcel;
|
||||||
|
|
||||||
|
import com.android.server.connectivity.mdns.MdnsServiceInfo.TextEntry;
|
||||||
|
import com.android.testutils.DevSdkIgnoreRule;
|
||||||
|
import com.android.testutils.DevSdkIgnoreRunner;
|
||||||
|
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.junit.runner.RunWith;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@RunWith(DevSdkIgnoreRunner.class)
|
||||||
|
@DevSdkIgnoreRule.IgnoreUpTo(SC_V2)
|
||||||
|
public class MdnsServiceInfoTest {
|
||||||
|
@Test
|
||||||
|
public void constructor_createWithOnlyTextStrings_correctAttributes() {
|
||||||
|
MdnsServiceInfo info =
|
||||||
|
new MdnsServiceInfo(
|
||||||
|
"my-mdns-service",
|
||||||
|
new String[] {"_googlecast", "_tcp"},
|
||||||
|
List.of(),
|
||||||
|
new String[] {"my-host", "local"},
|
||||||
|
12345,
|
||||||
|
"192.168.1.1",
|
||||||
|
"2001::1",
|
||||||
|
List.of("vn=Google Inc.", "mn=Google Nest Hub Max"),
|
||||||
|
/* textEntries= */ null);
|
||||||
|
|
||||||
|
assertTrue(info.getAttributeByKey("vn").equals("Google Inc."));
|
||||||
|
assertTrue(info.getAttributeByKey("mn").equals("Google Nest Hub Max"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void constructor_createWithOnlyTextEntries_correctAttributes() {
|
||||||
|
MdnsServiceInfo info =
|
||||||
|
new MdnsServiceInfo(
|
||||||
|
"my-mdns-service",
|
||||||
|
new String[] {"_googlecast", "_tcp"},
|
||||||
|
List.of(),
|
||||||
|
new String[] {"my-host", "local"},
|
||||||
|
12345,
|
||||||
|
"192.168.1.1",
|
||||||
|
"2001::1",
|
||||||
|
/* textStrings= */ null,
|
||||||
|
List.of(MdnsServiceInfo.TextEntry.fromString("vn=Google Inc."),
|
||||||
|
MdnsServiceInfo.TextEntry.fromString("mn=Google Nest Hub Max")));
|
||||||
|
|
||||||
|
assertTrue(info.getAttributeByKey("vn").equals("Google Inc."));
|
||||||
|
assertTrue(info.getAttributeByKey("mn").equals("Google Nest Hub Max"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void constructor_createWithBothTextStringsAndTextEntries_acceptsOnlyTextEntries() {
|
||||||
|
MdnsServiceInfo info =
|
||||||
|
new MdnsServiceInfo(
|
||||||
|
"my-mdns-service",
|
||||||
|
new String[] {"_googlecast", "_tcp"},
|
||||||
|
List.of(),
|
||||||
|
new String[] {"my-host", "local"},
|
||||||
|
12345,
|
||||||
|
"192.168.1.1",
|
||||||
|
"2001::1",
|
||||||
|
List.of("vn=Alphabet Inc.", "mn=Google Nest Hub Max", "id=12345"),
|
||||||
|
List.of(
|
||||||
|
MdnsServiceInfo.TextEntry.fromString("vn=Google Inc."),
|
||||||
|
MdnsServiceInfo.TextEntry.fromString("mn=Google Nest Hub Max")));
|
||||||
|
|
||||||
|
assertEquals(Map.of("vn", "Google Inc.", "mn", "Google Nest Hub Max"),
|
||||||
|
info.getAttributes());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void constructor_createWithDuplicateKeys_acceptsTheFirstOne() {
|
||||||
|
MdnsServiceInfo info =
|
||||||
|
new MdnsServiceInfo(
|
||||||
|
"my-mdns-service",
|
||||||
|
new String[] {"_googlecast", "_tcp"},
|
||||||
|
List.of(),
|
||||||
|
new String[] {"my-host", "local"},
|
||||||
|
12345,
|
||||||
|
"192.168.1.1",
|
||||||
|
"2001::1",
|
||||||
|
List.of("vn=Alphabet Inc.", "mn=Google Nest Hub Max", "id=12345"),
|
||||||
|
List.of(MdnsServiceInfo.TextEntry.fromString("vn=Google Inc."),
|
||||||
|
MdnsServiceInfo.TextEntry.fromString("mn=Google Nest Hub Max"),
|
||||||
|
MdnsServiceInfo.TextEntry.fromString("mn=Google WiFi Router")));
|
||||||
|
|
||||||
|
assertEquals(Map.of("vn", "Google Inc.", "mn", "Google Nest Hub Max"),
|
||||||
|
info.getAttributes());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void parcelable_canBeParceledAndUnparceled() {
|
||||||
|
Parcel parcel = Parcel.obtain();
|
||||||
|
MdnsServiceInfo beforeParcel =
|
||||||
|
new MdnsServiceInfo(
|
||||||
|
"my-mdns-service",
|
||||||
|
new String[] {"_googlecast", "_tcp"},
|
||||||
|
List.of(),
|
||||||
|
new String[] {"my-host", "local"},
|
||||||
|
12345,
|
||||||
|
"192.168.1.1",
|
||||||
|
"2001::1",
|
||||||
|
List.of("vn=Alphabet Inc.", "mn=Google Nest Hub Max", "id=12345"),
|
||||||
|
List.of(
|
||||||
|
MdnsServiceInfo.TextEntry.fromString("vn=Google Inc."),
|
||||||
|
MdnsServiceInfo.TextEntry.fromString("mn=Google Nest Hub Max")));
|
||||||
|
|
||||||
|
beforeParcel.writeToParcel(parcel, 0);
|
||||||
|
parcel.setDataPosition(0);
|
||||||
|
MdnsServiceInfo afterParcel = MdnsServiceInfo.CREATOR.createFromParcel(parcel);
|
||||||
|
|
||||||
|
assertEquals(beforeParcel.getServiceInstanceName(), afterParcel.getServiceInstanceName());
|
||||||
|
assertArrayEquals(beforeParcel.getServiceType(), afterParcel.getServiceType());
|
||||||
|
assertEquals(beforeParcel.getSubtypes(), afterParcel.getSubtypes());
|
||||||
|
assertArrayEquals(beforeParcel.getHostName(), afterParcel.getHostName());
|
||||||
|
assertEquals(beforeParcel.getPort(), afterParcel.getPort());
|
||||||
|
assertEquals(beforeParcel.getIpv4Address(), afterParcel.getIpv4Address());
|
||||||
|
assertEquals(beforeParcel.getIpv6Address(), afterParcel.getIpv6Address());
|
||||||
|
assertEquals(beforeParcel.getAttributes(), afterParcel.getAttributes());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void textEntry_parcelable_canBeParceledAndUnparceled() {
|
||||||
|
Parcel parcel = Parcel.obtain();
|
||||||
|
TextEntry beforeParcel = new TextEntry("AA", new byte[] {(byte) 0xFF, (byte) 0xFC});
|
||||||
|
|
||||||
|
beforeParcel.writeToParcel(parcel, 0);
|
||||||
|
parcel.setDataPosition(0);
|
||||||
|
TextEntry afterParcel = TextEntry.CREATOR.createFromParcel(parcel);
|
||||||
|
|
||||||
|
assertEquals(beforeParcel, afterParcel);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void textEntry_fromString_keyValueAreExpected() {
|
||||||
|
TextEntry entry = TextEntry.fromString("AA=xxyyzz");
|
||||||
|
|
||||||
|
assertEquals("AA", entry.getKey());
|
||||||
|
assertArrayEquals(new byte[] {'x', 'x', 'y', 'y', 'z', 'z'}, entry.getValue());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void textEntry_fromStringToString_textUnchanged() {
|
||||||
|
TextEntry entry = TextEntry.fromString("AA=xxyyzz");
|
||||||
|
|
||||||
|
assertEquals("AA=xxyyzz", entry.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void textEntry_fromStringWithoutAssignPunc_valueisEmpty() {
|
||||||
|
TextEntry entry = TextEntry.fromString("AA");
|
||||||
|
|
||||||
|
assertEquals("AA", entry.getKey());
|
||||||
|
assertArrayEquals(new byte[] {}, entry.getValue());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void textEntry_fromStringAssignPuncAtBeginning_returnsNull() {
|
||||||
|
TextEntry entry = TextEntry.fromString("=AA");
|
||||||
|
|
||||||
|
assertNull(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void textEntry_fromBytes_keyAndValueAreExpected() {
|
||||||
|
TextEntry entry = TextEntry.fromBytes(
|
||||||
|
new byte[] {'A', 'A', '=', 'x', 'x', 'y', 'y', 'z', 'z'});
|
||||||
|
|
||||||
|
assertEquals("AA", entry.getKey());
|
||||||
|
assertArrayEquals(new byte[] {'x', 'x', 'y', 'y', 'z', 'z'}, entry.getValue());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void textEntry_fromBytesToBytes_textUnchanged() {
|
||||||
|
TextEntry entry = TextEntry.fromBytes(
|
||||||
|
new byte[] {'A', 'A', '=', 'x', 'x', 'y', 'y', 'z', 'z'});
|
||||||
|
|
||||||
|
assertArrayEquals(new byte[] {'A', 'A', '=', 'x', 'x', 'y', 'y', 'z', 'z'},
|
||||||
|
entry.toBytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void textEntry_fromBytesWithoutAssignPunc_valueisEmpty() {
|
||||||
|
TextEntry entry = TextEntry.fromBytes(new byte[] {'A', 'A'});
|
||||||
|
|
||||||
|
assertEquals("AA", entry.getKey());
|
||||||
|
assertArrayEquals(new byte[] {}, entry.getValue());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void textEntry_fromBytesAssignPuncAtBeginning_returnsNull() {
|
||||||
|
TextEntry entry = TextEntry.fromBytes(new byte[] {'=', 'A', 'A'});
|
||||||
|
|
||||||
|
assertNull(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void textEntry_fromNonUtf8Bytes_keyValueAreExpected() {
|
||||||
|
TextEntry entry = TextEntry.fromBytes(
|
||||||
|
new byte[] {'A', 'A', '=', (byte) 0xFF, (byte) 0xFE, (byte) 0xFD});
|
||||||
|
|
||||||
|
assertEquals("AA", entry.getKey());
|
||||||
|
assertArrayEquals(new byte[] {(byte) 0xFF, (byte) 0xFE, (byte) 0xFD}, entry.getValue());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void textEntry_equals() {
|
||||||
|
assertEquals(new TextEntry("AA", "xxyyzz"), new TextEntry("AA", "xxyyzz"));
|
||||||
|
assertEquals(new TextEntry("BB", "xxyyzz"), new TextEntry("BB", "xxyyzz"));
|
||||||
|
assertEquals(new TextEntry("AA", "XXYYZZ"), new TextEntry("AA", "XXYYZZ"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,8 +32,11 @@ import static org.mockito.Mockito.times;
|
|||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import static java.nio.charset.StandardCharsets.UTF_8;
|
||||||
|
|
||||||
import android.annotation.NonNull;
|
import android.annotation.NonNull;
|
||||||
|
|
||||||
|
import com.android.server.connectivity.mdns.MdnsServiceInfo.TextEntry;
|
||||||
import com.android.server.connectivity.mdns.MdnsServiceTypeClient.QueryTaskConfig;
|
import com.android.server.connectivity.mdns.MdnsServiceTypeClient.QueryTaskConfig;
|
||||||
import com.android.testutils.DevSdkIgnoreRule;
|
import com.android.testutils.DevSdkIgnoreRule;
|
||||||
import com.android.testutils.DevSdkIgnoreRunner;
|
import com.android.testutils.DevSdkIgnoreRunner;
|
||||||
@@ -53,7 +56,6 @@ import java.net.DatagramPacket;
|
|||||||
import java.net.Inet4Address;
|
import java.net.Inet4Address;
|
||||||
import java.net.Inet6Address;
|
import java.net.Inet6Address;
|
||||||
import java.net.SocketAddress;
|
import java.net.SocketAddress;
|
||||||
import java.net.UnknownHostException;
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -495,7 +497,7 @@ public class MdnsServiceTypeClientTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void reportExistingServiceToNewlyRegisteredListeners() throws UnknownHostException {
|
public void reportExistingServiceToNewlyRegisteredListeners() throws Exception {
|
||||||
// Process the initial response.
|
// Process the initial response.
|
||||||
MdnsResponse initialResponse =
|
MdnsResponse initialResponse =
|
||||||
createResponse(
|
createResponse(
|
||||||
@@ -732,7 +734,7 @@ public class MdnsServiceTypeClientTests {
|
|||||||
int port,
|
int port,
|
||||||
@NonNull List<String> subtypes,
|
@NonNull List<String> subtypes,
|
||||||
@NonNull Map<String, String> textAttributes)
|
@NonNull Map<String, String> textAttributes)
|
||||||
throws UnknownHostException {
|
throws Exception {
|
||||||
String[] hostName = new String[]{"hostname"};
|
String[] hostName = new String[]{"hostname"};
|
||||||
MdnsServiceRecord serviceRecord = mock(MdnsServiceRecord.class);
|
MdnsServiceRecord serviceRecord = mock(MdnsServiceRecord.class);
|
||||||
when(serviceRecord.getServiceHost()).thenReturn(hostName);
|
when(serviceRecord.getServiceHost()).thenReturn(hostName);
|
||||||
@@ -753,10 +755,13 @@ public class MdnsServiceTypeClientTests {
|
|||||||
|
|
||||||
MdnsTextRecord textRecord = mock(MdnsTextRecord.class);
|
MdnsTextRecord textRecord = mock(MdnsTextRecord.class);
|
||||||
List<String> textStrings = new ArrayList<>();
|
List<String> textStrings = new ArrayList<>();
|
||||||
|
List<TextEntry> textEntries = new ArrayList<>();
|
||||||
for (Map.Entry<String, String> kv : textAttributes.entrySet()) {
|
for (Map.Entry<String, String> kv : textAttributes.entrySet()) {
|
||||||
textStrings.add(kv.getKey() + "=" + kv.getValue());
|
textStrings.add(kv.getKey() + "=" + kv.getValue());
|
||||||
|
textEntries.add(new TextEntry(kv.getKey(), kv.getValue().getBytes(UTF_8)));
|
||||||
}
|
}
|
||||||
when(textRecord.getStrings()).thenReturn(textStrings);
|
when(textRecord.getStrings()).thenReturn(textStrings);
|
||||||
|
when(textRecord.getEntries()).thenReturn(textEntries);
|
||||||
|
|
||||||
response.setServiceRecord(serviceRecord);
|
response.setServiceRecord(serviceRecord);
|
||||||
response.setTextRecord(textRecord);
|
response.setTextRecord(textRecord);
|
||||||
|
|||||||
Reference in New Issue
Block a user