org.apache.storm.daemon.logviewer.handler.LogviewerLogSearchHandler.java Source code

Java tutorial

Introduction

Here is the source code for org.apache.storm.daemon.logviewer.handler.LogviewerLogSearchHandler.java

Source

/*
 * 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 org.apache.storm.daemon.logviewer.handler;

import static java.util.stream.Collectors.toList;
import static org.apache.storm.daemon.utils.ListFunctionalSupport.drop;
import static org.apache.storm.daemon.utils.ListFunctionalSupport.first;
import static org.apache.storm.daemon.utils.ListFunctionalSupport.last;
import static org.apache.storm.daemon.utils.ListFunctionalSupport.rest;
import static org.apache.storm.daemon.utils.PathUtil.truncatePathToLastElements;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.common.annotations.VisibleForTesting;

import java.io.BufferedInputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.UnsupportedEncodingException;
import java.net.UnknownHostException;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
import java.util.zip.GZIPInputStream;

import javax.ws.rs.core.Response;

import org.apache.commons.lang.BooleanUtils;
import org.apache.commons.lang.StringUtils;
import org.apache.storm.DaemonConfig;
import org.apache.storm.daemon.common.JsonResponseBuilder;
import org.apache.storm.daemon.logviewer.LogviewerConstant;
import org.apache.storm.daemon.logviewer.utils.DirectoryCleaner;
import org.apache.storm.daemon.logviewer.utils.LogviewerResponseBuilder;
import org.apache.storm.daemon.logviewer.utils.ResourceAuthorizer;
import org.apache.storm.daemon.logviewer.utils.WorkerLogs;
import org.apache.storm.daemon.utils.StreamUtil;
import org.apache.storm.daemon.utils.UrlBuilder;
import org.apache.storm.ui.InvalidRequestException;
import org.apache.storm.utils.ObjectReader;
import org.apache.storm.utils.ServerUtils;
import org.apache.storm.utils.Utils;
import org.json.simple.JSONAware;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class LogviewerLogSearchHandler {
    private static final Logger LOG = LoggerFactory.getLogger(LogviewerLogSearchHandler.class);

    public static final int GREP_MAX_SEARCH_SIZE = 1024;
    public static final int GREP_BUF_SIZE = 2048;
    public static final int GREP_CONTEXT_SIZE = 128;
    public static final Pattern WORKER_LOG_FILENAME_PATTERN = Pattern.compile("^worker.log(.*)");

    private final Map<String, Object> stormConf;
    private final String logRoot;
    private final String daemonLogRoot;
    private final ResourceAuthorizer resourceAuthorizer;
    private final Integer logviewerPort;

    /**
     * Constructor.
     *
     * @param stormConf storm configuration
     * @param logRoot log root directory
     * @param daemonLogRoot daemon log root directory
     * @param resourceAuthorizer {@link ResourceAuthorizer}
     */
    public LogviewerLogSearchHandler(Map<String, Object> stormConf, String logRoot, String daemonLogRoot,
            ResourceAuthorizer resourceAuthorizer) {
        this.stormConf = stormConf;
        this.logRoot = logRoot;
        this.daemonLogRoot = daemonLogRoot;
        this.resourceAuthorizer = resourceAuthorizer;

        this.logviewerPort = ObjectReader.getInt(stormConf.get(DaemonConfig.LOGVIEWER_PORT));
    }

    /**
     * Search from a worker log file.
     *
     * @param fileName log file
     * @param user username
     * @param isDaemon whether the log file is regarding worker or daemon
     * @param search search string
     * @param numMatchesStr the count of maximum matches
     * @param offsetStr start offset for log file
     * @param callback callback for JSONP
     * @param origin origin
     * @return Response containing JSON content representing search result
     */
    public Response searchLogFile(String fileName, String user, boolean isDaemon, String search,
            String numMatchesStr, String offsetStr, String callback, String origin)
            throws IOException, InvalidRequestException {
        String rootDir = isDaemon ? daemonLogRoot : logRoot;
        File file = new File(rootDir, fileName).getCanonicalFile();
        Response response;
        if (file.exists()) {
            if (isDaemon || resourceAuthorizer.isUserAllowedToAccessFile(user, fileName)) {
                Integer numMatchesInt = numMatchesStr != null ? tryParseIntParam("num-matches", numMatchesStr)
                        : null;
                Integer offsetInt = offsetStr != null ? tryParseIntParam("start-byte-offset", offsetStr) : null;

                try {
                    if (StringUtils.isNotEmpty(search) && search.getBytes("UTF-8").length <= GREP_MAX_SEARCH_SIZE) {
                        Map<String, Object> entity = new HashMap<>();
                        entity.put("isDaemon", isDaemon ? "yes" : "no");
                        entity.putAll(substringSearch(file, search, isDaemon, numMatchesInt, offsetInt));

                        response = LogviewerResponseBuilder.buildSuccessJsonResponse(entity, callback, origin);
                    } else {
                        throw new InvalidRequestException(
                                "Search substring must be between 1 and 1024 " + "UTF-8 bytes in size (inclusive)");
                    }
                } catch (Exception ex) {
                    response = LogviewerResponseBuilder.buildExceptionJsonResponse(ex, callback);
                }
            } else {
                // unauthorized
                response = LogviewerResponseBuilder.buildUnauthorizedUserJsonResponse(user, callback);
            }
        } else {
            // not found
            Map<String, String> entity = new HashMap<>();
            entity.put("error", "Not Found");
            entity.put("errorMessage", "The file was not found on this node.");

            response = new JsonResponseBuilder().setData(entity).setCallback(callback).setStatus(404).build();
        }

        return response;
    }

    /**
     * Deep search across worker log files in a topology.
     *
     * @param topologyId topology ID
     * @param user username
     * @param search search string
     * @param numMatchesStr the count of maximum matches
     * @param portStr worker port, null or '*' if the request wants to search from all worker logs
     * @param fileOffsetStr index (offset) of the log files
     * @param offsetStr start offset for log file
     * @param searchArchived true if the request wants to search also archived files, false if not
     * @param callback callback for JSONP
     * @param origin origin
     * @return Response containing JSON content representing search result
     */
    public Response deepSearchLogsForTopology(String topologyId, String user, String search, String numMatchesStr,
            String portStr, String fileOffsetStr, String offsetStr, Boolean searchArchived, String callback,
            String origin) {
        String rootDir = logRoot;
        Object returnValue;
        File topologyDir = new File(rootDir, topologyId);
        if (StringUtils.isEmpty(search) || !topologyDir.exists()) {
            returnValue = new ArrayList<>();
        } else {
            int fileOffset = ObjectReader.getInt(fileOffsetStr, 0);
            int offset = ObjectReader.getInt(offsetStr, 0);
            int numMatches = ObjectReader.getInt(numMatchesStr, 1);

            File[] portDirsArray = topologyDir.listFiles();
            List<File> portDirs;
            if (portDirsArray != null) {
                portDirs = Arrays.asList(portDirsArray);
            } else {
                portDirs = new ArrayList<>();
            }

            if (StringUtils.isEmpty(portStr) || portStr.equals("*")) {
                // check for all ports
                List<List<File>> filteredLogs = portDirs.stream().map(portDir -> logsForPort(user, portDir))
                        .filter(logs -> logs != null && !logs.isEmpty()).collect(toList());

                if (BooleanUtils.isTrue(searchArchived)) {
                    returnValue = filteredLogs.stream().map(fl -> findNMatches(fl, numMatches, 0, 0, search))
                            .collect(toList());
                } else {
                    returnValue = filteredLogs.stream().map(fl -> Collections.singletonList(first(fl)))
                            .map(fl -> findNMatches(fl, numMatches, 0, 0, search)).collect(toList());
                }
            } else {
                int port = Integer.parseInt(portStr);
                // check just the one port
                List<Integer> slotsPorts = (List<Integer>) stormConf
                        .getOrDefault(DaemonConfig.SUPERVISOR_SLOTS_PORTS, new ArrayList<>());
                boolean containsPort = slotsPorts.stream()
                        .anyMatch(slotPort -> slotPort != null && (slotPort == port));
                if (!containsPort) {
                    returnValue = new ArrayList<>();
                } else {
                    File portDir = new File(rootDir + File.separator + topologyId + File.separator + port);

                    if (!portDir.exists() || logsForPort(user, portDir).isEmpty()) {
                        returnValue = new ArrayList<>();
                    } else {
                        List<File> filteredLogs = logsForPort(user, portDir);
                        if (BooleanUtils.isTrue(searchArchived)) {
                            returnValue = findNMatches(filteredLogs, numMatches, fileOffset, offset, search);
                        } else {
                            returnValue = findNMatches(Collections.singletonList(first(filteredLogs)), numMatches,
                                    0, offset, search);
                        }
                    }
                }
            }
        }

        return LogviewerResponseBuilder.buildSuccessJsonResponse(returnValue, callback, origin);
    }

    private Integer tryParseIntParam(String paramName, String value) throws InvalidRequestException {
        try {
            return Integer.parseInt(value);
        } catch (NumberFormatException e) {
            throw new InvalidRequestException("Could not parse " + paramName + " to an integer");
        }
    }

    @VisibleForTesting
    Map<String, Object> substringSearch(File file, String searchString) throws InvalidRequestException {
        return substringSearch(file, searchString, false, 10, 0);
    }

    @VisibleForTesting
    Map<String, Object> substringSearch(File file, String searchString, int numMatches)
            throws InvalidRequestException {
        return substringSearch(file, searchString, false, numMatches, 0);
    }

    @VisibleForTesting
    Map<String, Object> substringSearch(File file, String searchString, int numMatches, int startByteOffset)
            throws InvalidRequestException {
        return substringSearch(file, searchString, false, numMatches, startByteOffset);
    }

    private Map<String, Object> substringSearch(File file, String searchString, boolean isDaemon,
            Integer numMatches, Integer startByteOffset) throws InvalidRequestException {
        try {
            if (StringUtils.isEmpty(searchString)) {
                throw new IllegalArgumentException("Precondition fails: search string should not be empty.");
            }
            if (searchString.getBytes(StandardCharsets.UTF_8).length > GREP_MAX_SEARCH_SIZE) {
                throw new IllegalArgumentException(
                        "Precondition fails: the length of search string should be less than "
                                + GREP_MAX_SEARCH_SIZE);
            }

            boolean isZipFile = file.getName().endsWith(".gz");
            try (InputStream fis = Files.newInputStream(file.toPath());
                    InputStream gzippedInputStream = isZipFile ? new GZIPInputStream(fis) : fis;
                    BufferedInputStream stream = new BufferedInputStream(gzippedInputStream)) {

                int fileLength;
                if (isZipFile) {
                    fileLength = (int) ServerUtils.zipFileSize(file);
                } else {
                    fileLength = (int) file.length();
                }

                ByteBuffer buf = ByteBuffer.allocate(GREP_BUF_SIZE);
                final byte[] bufArray = buf.array();
                final byte[] searchBytes = searchString.getBytes(StandardCharsets.UTF_8);
                numMatches = numMatches != null ? numMatches : 10;
                startByteOffset = startByteOffset != null ? startByteOffset : 0;

                // Start at the part of the log file we are interested in.
                // Allow searching when start-byte-offset == file-len so it doesn't blow up on 0-length files
                if (startByteOffset > fileLength) {
                    throw new InvalidRequestException("Cannot search past the end of the file");
                }

                if (startByteOffset > 0) {
                    StreamUtil.skipBytes(stream, startByteOffset);
                }

                Arrays.fill(bufArray, (byte) 0);

                int totalBytesRead = 0;
                int bytesRead = stream.read(bufArray, 0, Math.min((int) fileLength, GREP_BUF_SIZE));
                buf.limit(bytesRead);
                totalBytesRead += bytesRead;

                List<Map<String, Object>> initialMatches = new ArrayList<>();
                int initBufOffset = 0;
                int byteOffset = startByteOffset;
                byte[] beforeBytes = null;

                Map<String, Object> ret = new HashMap<>();
                while (true) {
                    SubstringSearchResult searchRet = bufferSubstringSearch(isDaemon, file, fileLength, byteOffset,
                            initBufOffset, stream, startByteOffset, totalBytesRead, buf, searchBytes,
                            initialMatches, numMatches, beforeBytes);

                    List<Map<String, Object>> matches = searchRet.getMatches();
                    Integer newByteOffset = searchRet.getNewByteOffset();
                    byte[] newBeforeBytes = searchRet.getNewBeforeBytes();

                    if (matches.size() < numMatches && totalBytesRead + startByteOffset < fileLength) {
                        // The start index is positioned to find any possible
                        // occurrence search string that did not quite fit in the
                        // buffer on the previous read.
                        final int newBufOffset = Math.min(buf.limit(), GREP_MAX_SEARCH_SIZE) - searchBytes.length;

                        totalBytesRead = rotateGrepBuffer(buf, stream, totalBytesRead, file, fileLength);
                        if (totalBytesRead < 0) {
                            throw new InvalidRequestException("Cannot search past the end of the file");
                        }

                        initialMatches = matches;
                        initBufOffset = newBufOffset;
                        byteOffset = newByteOffset;
                        beforeBytes = newBeforeBytes;
                    } else {
                        ret.put("isDaemon", isDaemon ? "yes" : "no");
                        Integer nextByteOffset = null;
                        if (matches.size() >= numMatches || totalBytesRead < fileLength) {
                            nextByteOffset = (Integer) last(matches).get("byteOffset") + searchBytes.length;
                            if (fileLength <= nextByteOffset) {
                                nextByteOffset = null;
                            }
                        }
                        ret.putAll(mkGrepResponse(searchBytes, startByteOffset, matches, nextByteOffset));
                        break;
                    }
                }
                return ret;
            }
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

    @VisibleForTesting
    Map<String, Object> substringSearchDaemonLog(File file, String searchString) throws InvalidRequestException {
        return substringSearch(file, searchString, true, 10, 0);
    }

    /**
     * Get the filtered, authorized, sorted log files for a port.
     */
    @VisibleForTesting
    List<File> logsForPort(String user, File portDir) {
        try {
            List<File> workerLogs = DirectoryCleaner.getFilesForDir(portDir).stream()
                    .filter(file -> WORKER_LOG_FILENAME_PATTERN.asPredicate().test(file.getName()))
                    .collect(toList());

            return workerLogs.stream()
                    .filter(log -> resourceAuthorizer.isUserAllowedToAccessFile(user,
                            WorkerLogs.getTopologyPortWorkerLog(log)))
                    .sorted((f1, f2) -> (int) (f2.lastModified() - f1.lastModified())).collect(toList());
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

    @VisibleForTesting
    Matched findNMatches(List<File> logs, int numMatches, int fileOffset, int offset, String search) {
        logs = drop(logs, fileOffset);

        List<Map<String, Object>> matches = new ArrayList<>();
        int matchCount = 0;

        while (true) {
            if (logs.isEmpty()) {
                break;
            }

            File firstLog = logs.get(0);
            Map<String, Object> theseMatches;
            try {
                LOG.debug("Looking through {}", firstLog);
                theseMatches = substringSearch(firstLog, search, numMatches - matchCount, offset);
            } catch (InvalidRequestException e) {
                LOG.error("Can't search past end of file.", e);
                theseMatches = new HashMap<>();
            }

            String fileName = WorkerLogs.getTopologyPortWorkerLog(firstLog);

            final List<Map<String, Object>> newMatches = new ArrayList<>(matches);
            Map<String, Object> currentFileMatch = new HashMap<>(theseMatches);
            currentFileMatch.put("fileName", fileName);
            Path firstLogAbsPath;
            try {
                firstLogAbsPath = firstLog.getCanonicalFile().toPath();
            } catch (IOException e) {
                throw new RuntimeException(e);
            }
            currentFileMatch.put("port", truncatePathToLastElements(firstLogAbsPath, 2).getName(0));
            newMatches.add(currentFileMatch);

            int newCount = matchCount + ((List<?>) theseMatches.get("matches")).size();

            if (theseMatches.isEmpty()) {
                // matches and matchCount is not changed
                logs = rest(logs);
                offset = 0;
                fileOffset = fileOffset + 1;
            } else if (newCount >= numMatches) {
                matches = newMatches;
                break;
            } else {
                matches = newMatches;
                logs = rest(logs);
                offset = 0;
                fileOffset = fileOffset + 1;
                matchCount = newCount;
            }
        }

        return new Matched(fileOffset, search, matches);
    }

    /**
     * As the file is read into a buffer, 1/2 the buffer's size at a time, we search the buffer for matches of the
     * substring and return a list of zero or more matches.
     */
    private SubstringSearchResult bufferSubstringSearch(boolean isDaemon, File file, int fileLength,
            int offsetToBuf, int initBufOffset, BufferedInputStream stream, Integer bytesSkipped, int bytesRead,
            ByteBuffer haystack, byte[] needle, List<Map<String, Object>> initialMatches, Integer numMatches,
            byte[] beforeBytes) throws IOException {
        int bufOffset = initBufOffset;
        List<Map<String, Object>> matches = initialMatches;

        byte[] newBeforeBytes;
        Integer newByteOffset;

        while (true) {
            int offset = offsetOfBytes(haystack.array(), needle, bufOffset);
            if (matches.size() < numMatches && offset >= 0) {
                final int fileOffset = offsetToBuf + offset;
                final int bytesNeededAfterMatch = haystack.limit() - GREP_CONTEXT_SIZE - needle.length;

                byte[] beforeArg = null;
                byte[] afterArg = null;
                if (offset < GREP_CONTEXT_SIZE) {
                    beforeArg = beforeBytes;
                }

                if (offset > bytesNeededAfterMatch) {
                    afterArg = tryReadAhead(stream, haystack, offset, fileLength, bytesRead);
                }

                bufOffset = offset + needle.length;
                matches.add(mkMatchData(needle, haystack, offset, fileOffset, file.getCanonicalFile().toPath(),
                        isDaemon, beforeArg, afterArg));
            } else {
                int beforeStrToOffset = Math.min(haystack.limit(), GREP_MAX_SEARCH_SIZE);
                int beforeStrFromOffset = Math.max(0, beforeStrToOffset - GREP_CONTEXT_SIZE);
                newBeforeBytes = Arrays.copyOfRange(haystack.array(), beforeStrFromOffset, beforeStrToOffset);

                // It's OK if new-byte-offset is negative.
                // This is normal if we are out of bytes to read from a small file.
                if (matches.size() >= numMatches) {
                    newByteOffset = ((Number) last(matches).get("byteOffset")).intValue() + needle.length;
                } else {
                    newByteOffset = bytesSkipped + bytesRead - GREP_MAX_SEARCH_SIZE;
                }

                break;
            }
        }

        return new SubstringSearchResult(matches, newByteOffset, newBeforeBytes);
    }

    private int rotateGrepBuffer(ByteBuffer buf, BufferedInputStream stream, int totalBytesRead, File file,
            int fileLength) throws IOException {
        byte[] bufArray = buf.array();

        // Copy the 2nd half of the buffer to the first half.
        System.arraycopy(bufArray, GREP_MAX_SEARCH_SIZE, bufArray, 0, GREP_MAX_SEARCH_SIZE);

        // Zero-out the 2nd half to prevent accidental matches.
        Arrays.fill(bufArray, GREP_MAX_SEARCH_SIZE, bufArray.length, (byte) 0);

        // Fill the 2nd half with new bytes from the stream.
        int bytesRead = stream.read(bufArray, GREP_MAX_SEARCH_SIZE,
                Math.min((int) fileLength, GREP_MAX_SEARCH_SIZE));
        buf.limit(GREP_MAX_SEARCH_SIZE + bytesRead);
        return totalBytesRead + bytesRead;
    }

    private Map<String, Object> mkMatchData(byte[] needle, ByteBuffer haystack, int haystackOffset, int fileOffset,
            Path canonicalPath, boolean isDaemon, byte[] beforeBytes, byte[] afterBytes)
            throws UnsupportedEncodingException, UnknownHostException {
        String url;
        if (isDaemon) {
            url = urlToMatchCenteredInLogPageDaemonFile(needle, canonicalPath, fileOffset, logviewerPort);
        } else {
            url = urlToMatchCenteredInLogPage(needle, canonicalPath, fileOffset, logviewerPort);
        }

        byte[] haystackBytes = haystack.array();
        String beforeString;
        String afterString;

        if (haystackOffset >= GREP_CONTEXT_SIZE) {
            beforeString = new String(haystackBytes, (haystackOffset - GREP_CONTEXT_SIZE), GREP_CONTEXT_SIZE,
                    "UTF-8");
        } else {
            int numDesired = Math.max(0, GREP_CONTEXT_SIZE - haystackOffset);
            int beforeSize = beforeBytes != null ? beforeBytes.length : 0;
            int numExpected = Math.min(beforeSize, numDesired);

            if (numExpected > 0) {
                StringBuilder sb = new StringBuilder();
                sb.append(new String(beforeBytes, beforeSize - numExpected, numExpected, "UTF-8"));
                sb.append(new String(haystackBytes, 0, haystackOffset, "UTF-8"));
                beforeString = sb.toString();
            } else {
                beforeString = new String(haystackBytes, 0, haystackOffset, "UTF-8");
            }
        }

        int needleSize = needle.length;
        int afterOffset = haystackOffset + needleSize;
        int haystackSize = haystack.limit();

        if ((afterOffset + GREP_CONTEXT_SIZE) < haystackSize) {
            afterString = new String(haystackBytes, afterOffset, GREP_CONTEXT_SIZE, "UTF-8");
        } else {
            int numDesired = GREP_CONTEXT_SIZE - (haystackSize - afterOffset);
            int afterSize = afterBytes != null ? afterBytes.length : 0;
            int numExpected = Math.min(afterSize, numDesired);

            if (numExpected > 0) {
                StringBuilder sb = new StringBuilder();
                sb.append(new String(haystackBytes, afterOffset, (haystackSize - afterOffset), "UTF-8"));
                sb.append(new String(afterBytes, 0, numExpected, "UTF-8"));
                afterString = sb.toString();
            } else {
                afterString = new String(haystackBytes, afterOffset, (haystackSize - afterOffset), "UTF-8");
            }
        }

        Map<String, Object> ret = new HashMap<>();
        ret.put("byteOffset", fileOffset);
        ret.put("beforeString", beforeString);
        ret.put("afterString", afterString);
        ret.put("matchString", new String(needle, "UTF-8"));
        ret.put("logviewerURL", url);

        return ret;
    }

    /**
     * Tries once to read ahead in the stream to fill the context and
     * resets the stream to its position before the call.
     */
    private byte[] tryReadAhead(BufferedInputStream stream, ByteBuffer haystack, int offset, int fileLength,
            int bytesRead) throws IOException {
        int numExpected = Math.min(fileLength - bytesRead, GREP_CONTEXT_SIZE);
        byte[] afterBytes = new byte[numExpected];
        stream.mark(numExpected);
        // Only try reading once.
        stream.read(afterBytes, 0, numExpected);
        stream.reset();
        return afterBytes;
    }

    /**
     * Searches a given byte array for a match of a sub-array of bytes.
     * Returns the offset to the byte that matches, or -1 if no match was found.
     */
    private int offsetOfBytes(byte[] buffer, byte[] search, int initOffset) {
        if (search.length <= 0) {
            throw new IllegalArgumentException("Search array should not be empty.");
        }

        if (initOffset < 0) {
            throw new IllegalArgumentException("Start offset shouldn't be negative.");
        }

        int offset = initOffset;
        int candidateOffset = initOffset;
        int valOffset = 0;
        int retOffset = 0;

        while (true) {
            if (search.length - valOffset <= 0) {
                // found
                retOffset = candidateOffset;
                break;
            } else {
                if (offset >= buffer.length) {
                    // We ran out of buffer for the search.
                    retOffset = -1;
                    break;
                } else {
                    if (search[valOffset] != buffer[offset]) {
                        // The match at this candidate offset failed, so start over with the
                        // next candidate byte from the buffer.
                        int newOffset = candidateOffset + 1;

                        offset = newOffset;
                        candidateOffset = newOffset;
                        valOffset = 0;
                    } else {
                        // So far it matches.  Keep going...
                        offset = offset + 1;
                        valOffset = valOffset + 1;
                    }
                }
            }
        }

        return retOffset;
    }

    /**
     * This response data only includes a next byte offset if there is more of the file to read.
     */
    private Map<String, Object> mkGrepResponse(byte[] searchBytes, Integer offset,
            List<Map<String, Object>> matches, Integer nextByteOffset) throws UnsupportedEncodingException {
        Map<String, Object> ret = new HashMap<>();
        ret.put("searchString", new String(searchBytes, "UTF-8"));
        ret.put("startByteOffset", offset);
        ret.put("matches", matches);
        if (nextByteOffset != null) {
            ret.put("nextByteOffset", nextByteOffset);
        }
        return ret;
    }

    @VisibleForTesting
    String urlToMatchCenteredInLogPage(byte[] needle, Path canonicalPath, int offset, Integer port)
            throws UnknownHostException {
        final String host = Utils.hostname();
        final Path truncatedFilePath = truncatePathToLastElements(canonicalPath, 3);

        Map<String, Object> parameters = new HashMap<>();
        parameters.put("file", truncatedFilePath.toString());
        parameters.put("start",
                Math.max(0, offset - (LogviewerConstant.DEFAULT_BYTES_PER_PAGE / 2) - (needle.length / -2)));
        parameters.put("length", LogviewerConstant.DEFAULT_BYTES_PER_PAGE);

        return UrlBuilder.build(String.format("http://%s:%d/api/v1/log", host, port), parameters);
    }

    @VisibleForTesting
    String urlToMatchCenteredInLogPageDaemonFile(byte[] needle, Path canonicalPath, int offset, Integer port)
            throws UnknownHostException {
        final String host = Utils.hostname();
        final Path truncatedFilePath = truncatePathToLastElements(canonicalPath, 1);

        Map<String, Object> parameters = new HashMap<>();
        parameters.put("file", truncatedFilePath.toString());
        parameters.put("start",
                Math.max(0, offset - (LogviewerConstant.DEFAULT_BYTES_PER_PAGE / 2) - (needle.length / -2)));
        parameters.put("length", LogviewerConstant.DEFAULT_BYTES_PER_PAGE);

        return UrlBuilder.build(String.format("http://%s:%d/api/v1/daemonlog", host, port), parameters);
    }

    @VisibleForTesting
    public static class Matched implements JSONAware {
        private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();

        private int fileOffset;
        private String searchString;
        private List<Map<String, Object>> matches;

        /**
         * Constructor.
         *
         * @param fileOffset offset (index) of the files
         * @param searchString search string
         * @param matches map representing matched search result
         */
        public Matched(int fileOffset, String searchString, List<Map<String, Object>> matches) {
            this.fileOffset = fileOffset;
            this.searchString = searchString;
            this.matches = matches;
        }

        public int getFileOffset() {
            return fileOffset;
        }

        public String getSearchString() {
            return searchString;
        }

        public List<Map<String, Object>> getMatches() {
            return matches;
        }

        @Override
        public String toJSONString() {
            try {
                return OBJECT_MAPPER.writeValueAsString(this);
            } catch (JsonProcessingException e) {
                throw new RuntimeException(e);
            }
        }
    }

    private static class SubstringSearchResult {
        private List<Map<String, Object>> matches;
        private Integer newByteOffset;
        private byte[] newBeforeBytes;

        public SubstringSearchResult(List<Map<String, Object>> matches, Integer newByteOffset,
                byte[] newBeforeBytes) {
            this.matches = matches;
            this.newByteOffset = newByteOffset;
            this.newBeforeBytes = newBeforeBytes;
        }

        public List<Map<String, Object>> getMatches() {
            return matches;
        }

        public Integer getNewByteOffset() {
            return newByteOffset;
        }

        public byte[] getNewBeforeBytes() {
            return newBeforeBytes;
        }
    }
}