Binary.java

/*
 * Copyright (c) 2016, Stein Eldar Johnsen
 *
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements. See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership. The ASF licenses this file
 * to you 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 net.morimekta.collect.util;

import net.morimekta.collect.SecureHashable;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.Serializable;
import java.nio.ByteBuffer;
import java.nio.charset.Charset;
import java.util.Base64;
import java.util.BitSet;
import java.util.Locale;

import static java.lang.Byte.toUnsignedInt;
import static java.util.Objects.requireNonNull;

/**
 * Simplistic byte sequence wrapper with lots of convenience methods. Used to
 * wrap byte arrays for the binary data type. This class does not give any
 * access not modifiers to it's contained byte array, so can be considered
 * fully immutable as long as the wrapped byte array is not modified.
 */
public final class Binary implements SecureHashable, Comparable<Binary>, Serializable {
    private static final long serialVersionUID = 196610438780516680L;

    /**
     * Create a binary instance that wraps a created byte array. Exposed so
     * it can be used in places where constructors are expected.
     *
     * @param bytes The byte array to wrap.
     */
    public Binary(byte[] bytes) {
        requireNonNull(bytes, "bytes == null");
        this.bytes = bytes;
        this.offset = 0;
        this.length = bytes.length;
    }

    /**
     * Create a binary instance that wraps a created byte array. Exposed so
     * it can be used in places where constructors are expected.
     *
     * @param bytes  The byte array to wrap.
     * @param offset The offset in the array.
     * @param length The length of array to wrap.
     */
    public Binary(byte[] bytes, int offset, int length) {
        requireNonNull(bytes, "bytes == null");
        if (offset < 0) {
            throw new IllegalArgumentException("offset " + offset + " < 0");
        }
        if (length < 0) {
            throw new IllegalArgumentException("length " + length + " < 0");
        }
        if (offset + length > bytes.length) {
            throw new IllegalArgumentException("End after end of bytes: " + offset + " + " + length + " > " + bytes.length);
        }
        this.bytes = bytes;
        this.offset = offset;
        this.length = length;
    }

    /**
     * Convenience method to wrap a byte array into a byte sequence.
     *
     * @param bytes Bytes to wrap.
     * @return The wrapped byte sequence.
     */
    public static Binary wrap(byte[] bytes) {
        requireNonNull(bytes, "bytes == null");
        if (bytes.length == 0) {
            return EMPTY;
        }
        return new Binary(bytes);
    }

    /**
     * Convenience method to copy a byte array into a byte sequence.
     *
     * @param bytes Bytes to wrap.
     * @return The wrapped byte sequence.
     */
    public static Binary copy(byte[] bytes) {
        return copy(bytes, 0, bytes.length);
    }

    /**
     * Convenience method to copy a part of a byte array into a byte sequence.
     *
     * @param bytes Bytes to wrap.
     * @param off   Offset in source bytes to start reading from.
     * @param len   Number of bytes to copy.
     * @return The wrapped byte sequence.
     */
    public static Binary copy(byte[] bytes, int off, int len) {
        requireNonNull(bytes, "bytes == null");
        if (len == 0) {
            return EMPTY;
        }
        byte[] cpy = new byte[len];
        System.arraycopy(bytes, off, cpy, 0, len);
        return wrap(cpy);
    }

    /**
     * Method to create a Binary with 0 bytes.
     *
     * @return Empty Binary object.
     */
    public static Binary empty() {
        return EMPTY;
    }

    /**
     * Get the length of the backing array.
     *
     * @return Byte count.
     */
    public int length() {
        return length;
    }

    /**
     * Get a copy of the backing array.
     *
     * @return The copy.
     */
    public byte[] get() {
        byte[] cpy = new byte[length];
        System.arraycopy(bytes, offset, cpy, 0, length);
        return cpy;
    }

    /**
     * Get a copy of the backing array.
     *
     * @param into Target ro copy into.
     * @return Number of bytes written.
     */
    public int get(byte[] into) {
        requireNonNull(into, "into == null");
        int len = Math.min(into.length, length);
        System.arraycopy(bytes, offset, into, 0, len);
        return len;
    }

    /**
     * Create a slice of the binary content. Will not copy the underlying bytes.
     *
     * @param start Start index of new slice.
     * @param end   End index of new slice (excluding).
     * @return The created slice.
     */
    public Binary slice(int start, int end) {
        if (start == 0 && end == length) {
            return this;
        }
        if (start == end) {
            return EMPTY;
        }
        if (start < 0) {
            throw new IllegalArgumentException("start " + start + " < 0");
        }
        if (start > end) {
            throw new IllegalArgumentException("start " + start + " > end " + end);
        }
        if (end > length) {
            throw new IllegalArgumentException("end " + end + " > length " + length);
        }
        return new Binary(bytes, offset + start, end - start);
    }

    /**
     * Create a slice of the binary content until it's end. Will not copy the
     * underlying bytes.
     *
     * @param start Start index of new slice.
     * @return The created slice.
     */
    public Binary slice(int start) {
        if (start == 0) {
            return this;
        }
        return slice(start, length);
    }

    /**
     * Decode base64 string and wrap the result in a byte sequence.
     *
     * @param base64 The string to decode.
     * @return The resulting sequence.
     */
    public static Binary fromBase64(String base64) {
        requireNonNull(base64, "base64 == null");
        if (base64.isEmpty()) {
            return EMPTY;
        }
        byte[] arr = Base64.getDecoder().decode(base64);
        return new Binary(arr);
    }

    /**
     * Get the sequence encoded as base64.
     *
     * @return The encoded string.
     */
    public String toBase64() {
        return Base64.getEncoder().withoutPadding().encodeToString(getOrSame());
    }

    /**
     * Parse a hex string as bytes.
     *
     * @param hex The hex string.
     * @return The corresponding bytes.
     * @throws NumberFormatException If the string is not valid as hex string.
     */
    public static Binary fromHexString(String hex) {
        requireNonNull(hex, "hex == null");
        if (hex.isEmpty()) {
            return EMPTY;
        }
        if (hex.length() % 2 != 0) {
            throw new IllegalArgumentException("Illegal hex string length: " + hex.length());
        }
        final int len = hex.length() / 2;
        final byte[] out = new byte[len];
        for (int i = 0; i < len; ++i) {
            int pos = i * 2;
            String part = hex.substring(pos, pos + 2);
            out[i] = (byte) Integer.parseInt(part, 16);
        }
        return new Binary(out);
    }

    /**
     * Make a hex string from a byte array.
     *
     * @return The hex string.
     */
    public String toHexString() {
        if (length == 0) {
            return "";
        }
        StringBuilder builder = new StringBuilder();
        for (int i = offset; i < (offset + length); ++i) {
            builder.append(String.format(Locale.US, "%02x", bytes[i]));
        }
        return builder.toString();
    }

    /**
     * Encode a string and return the data as a binary.
     *
     * @param str      The string to encode.
     * @param encoding The charset to encode with.
     * @return The encoded binary.
     */
    public static Binary encodeFromString(String str, Charset encoding) {
        byte[] data = str.getBytes(encoding);
        return wrap(data);
    }

    /**
     * Decode data as a string using the specified charset encoding.
     *
     * @param encoding The encoding to use.
     * @return The decoded string.
     */
    public String decodeToString(Charset encoding) {
        return new String(bytes, offset, length, encoding);
    }

    /**
     * Get a binary from the remaining content of the byte buffer.
     *
     * @param buffer The buffer to get bytes from.
     * @return The resulting binary.
     */
    public static Binary fromByteBuffer(ByteBuffer buffer) {
        if (buffer.remaining() < 1) {
            return EMPTY;
        }
        byte[] data = new byte[buffer.remaining()];
        buffer.get(data);
        return new Binary(data);
    }

    /**
     * Get a byte buffer wrapping the binary data.
     *
     * @return A byte buffer.
     */
    public ByteBuffer getByteBuffer() {
        return ByteBuffer.wrap(get());
    }

    /**
     * Get a binary from the byte representation of a bitset.
     *
     * @param bitSet The bitset to get binary bytes from.
     * @return The binary.
     */
    public static Binary fromBitSet(BitSet bitSet) {
        return new Binary(bitSet.toByteArray());
    }

    /**
     * Get a bitset from the binary data.
     *
     * @return The resulting bitset.
     */
    public BitSet toBitSet() {
        return BitSet.valueOf(getOrSame());
    }

    /**
     * Get an input stream that reads from the stored bytes.
     *
     * @return An input stream.
     */
    public InputStream getInputStream() {
        return new ByteArrayInputStream(bytes, offset, length);
    }

    /**
     * Read a binary buffer from input stream. This will read all of the
     * content of the stream, and put in the binary.
     *
     * @param in Input stream to read.
     * @return The binary instance.
     * @throws IOException If unable to read completely what's expected.
     */
    public static Binary read(InputStream in) throws IOException {
        ByteArrayOutputStream tmp = new ByteArrayOutputStream();
        byte[] buf = new byte[4096];
        int l;
        while ((l = in.read(buf)) > 0) {
            tmp.write(buf, 0, l);
        }
        if (tmp.size() == 0) {
            return EMPTY;
        }
        return wrap(tmp.toByteArray());
    }

    /**
     * Read a binary buffer from input stream. This will read a specific
     * number of bytes form the stream, and wrap in the binary instance.
     *
     * @param in  Input stream to read.
     * @param len Number of bytes to read.
     * @return The binary instance.
     * @throws IOException If unable to read completely what's expected.
     */
    public static Binary read(InputStream in, int len) throws IOException {
        if (len == 0) {
            return EMPTY;
        }
        byte[] bytes = new byte[len];
        int pos = 0;
        while (pos < len) {
            int i = in.read(bytes, pos, len - pos);
            if (i <= 0) {
                throw new IOException("End of stream before complete buffer read.");
            }
            pos += i;
        }
        return wrap(bytes);
    }

    /**
     * Write bytes to output stream.
     *
     * @param out Stream to write to.
     * @return Number of bytes written.
     * @throws IOException When unable to write to stream.
     */
    public int write(OutputStream out) throws IOException {
        out.write(bytes, offset, length);
        return length;
    }

    // --- Comparable ---

    @Override
    public int compareTo(Binary other) {
        final int c = Math.min(length, other.length);
        for (int i = 0; i < c; ++i) {
            if (bytes[offset + i] != other.bytes[other.offset + i]) {
                return bytes[offset + i] > other.bytes[other.offset + i] ? 1 : -1;
            }
        }
        if (length == other.length) {
            return 0;
        }
        return length > other.length ? 1 : -1;
    }

    // --- Object ---

    @Override
    public boolean equals(Object o) {
        if (o == this) {
            return true;
        }
        if (!(o instanceof Binary)) {
            return false;
        }
        Binary other = (Binary) o;
        if (other.length != length) {
            return false;
        }
        if (other.bytes == bytes && other.offset == offset) {
            // same data, same offset (and same length).
            return true;
        }
        for (int i = 0; i < length; ++i) {
            if (bytes[offset + i] != other.bytes[other.offset + i]) {
                return false;
            }
        }
        return true;
    }

    @Override
    public int hashCode() {
        if (hash == null) {
            int result = 5023;
            for (int i = 0; i < length; ++i) {
                result *= 3119;
                result += (512 + toUnsignedInt(bytes[i + offset])) * 9013;
            }
            hash = result;
        }
        return hash;
    }

    @Override
    public long secureHashCode() {
        if (secureHash == null) {
            secureHash = SecureHashable.secureHash(bytes, offset, length);
        }
        return secureHash;
    }

    @Override
    public String toString() {
        return "binary(" + toHexString() + ")";
    }

    private byte[] getOrSame() {
        if (offset == 0 && length == bytes.length) {
            return bytes;
        }
        return get();
    }

    private final     byte[]  bytes;
    private final     int     offset;
    private final     int     length;
    private transient Integer hash;
    private transient Long    secureHash;

    private static final Binary EMPTY = new Binary(new byte[0]);
}