// Copyright 2015 The Bazel Authors. All rights reserved.
//
// 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.google.devtools.build.lib.bazel.repository.decompressor;

import static com.google.devtools.build.lib.bazel.repository.decompressor.StripPrefixedPath.maybeDeprefixSymlink;
import static java.nio.charset.StandardCharsets.ISO_8859_1;

import com.google.auto.service.AutoService;
import com.google.common.io.ByteStreams;
import com.google.devtools.build.lib.bazel.repository.decompressor.DecompressorValue.Decompressor;
import com.google.devtools.build.lib.util.StringEncoding;
import com.google.devtools.build.lib.vfs.FileSystemUtils;
import com.google.devtools.build.lib.vfs.Path;
import com.google.devtools.build.lib.vfs.PathFragment;
import java.io.BufferedInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.ByteBuffer;
import java.nio.CharBuffer;
import java.nio.charset.Charset;
import java.nio.charset.CharsetDecoder;
import java.nio.charset.CharsetEncoder;
import java.nio.charset.CoderResult;
import java.nio.charset.spi.CharsetProvider;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import org.apache.commons.compress.archivers.tar.TarArchiveEntry;
import org.apache.commons.compress.archivers.tar.TarArchiveInputStream;

/**
 * Common code for unarchiving a compressed TAR file.
 *
 * <p>TAR file entries commonly use one of two formats: PAX, which uses UTF-8 encoding for all
 * strings, and USTAR, which does not specify an encoding. This class interprets USTAR headers as
 * latin-1, thus preserving the original bytes of the header without enforcing any particular
 * encoding. Internally, for file system operations, all strings are converted into Bazel's internal
 * representation of raw bytes stored as latin-1 strings.
 */
public abstract class CompressedTarFunction implements Decompressor {
  private static final int BUFFER_SIZE = 32 * 1024;

  protected abstract InputStream getDecompressorStream(BufferedInputStream compressedInputStream)
      throws IOException;

  @Override
  public Path decompress(DecompressorDescriptor descriptor)
      throws InterruptedException, IOException {
    if (Thread.interrupted()) {
      throw new InterruptedException();
    }
    Optional<String> prefix = descriptor.prefix();
    Map<String, String> renameFiles = descriptor.renameFiles();
    boolean foundPrefix = false;
    Set<String> availablePrefixes = new HashSet<>();
    // Store link, target info of symlinks, we create them after regular files are extracted.
    Map<Path, PathFragment> symlinks = new HashMap<>();

    try (InputStream compressedInputStream = descriptor.archivePath().getInputStream();
        InputStream decompressorStream =
            getDecompressorStream(new BufferedInputStream(compressedInputStream, BUFFER_SIZE))) {
      // USTAR tar headers use an unspecified encoding whereas PAX tar headers always use UTF-8.
      // We can specify the encoding to use for USTAR headers, but the Charset used for PAX headers
      // is fixed to UTF-8. We thus specify a custom Charset for the former so that we can
      // distinguish between the two.
      TarArchiveInputStream tarStream =
          new TarArchiveInputStream(decompressorStream, MarkedIso88591Charset.NAME);
      TarArchiveEntry entry;
      while ((entry = tarStream.getNextTarEntry()) != null) {
        String entryName = toRawBytesString(entry.getName());
        entryName = renameFiles.getOrDefault(entryName, entryName);
        StripPrefixedPath entryPath =
            StripPrefixedPath.maybeDeprefix(entryName.getBytes(ISO_8859_1), prefix);
        foundPrefix = foundPrefix || entryPath.foundPrefix();

        if (prefix.isPresent() && !foundPrefix) {
          CouldNotFindPrefixException.maybeMakePrefixSuggestion(entryPath.getPathFragment())
              .ifPresent(availablePrefixes::add);
        }

        if (entryPath.skip()) {
          continue;
        }

        PathFragment strippedRelativePath = entryPath.getPathFragment();
        if (strippedRelativePath.isAbsolute()) {
          throw new IOException(
              String.format(
                  "Failed to extract %s, tarred paths cannot be absolute", strippedRelativePath));
        }

        strippedRelativePath = strippedRelativePath.stripComponents(descriptor.stripComponents());
        if (Objects.equals(strippedRelativePath, PathFragment.EMPTY_FRAGMENT)) {
          continue;
        }

        Path filePath = descriptor.destinationPath().getRelative(strippedRelativePath);
        if (!filePath.startsWith(descriptor.destinationPath())) {
          throw new IOException(
              String.format(
                  "Failed to extract %s, path is escaping the destination directory",
                  strippedRelativePath));
        }
        filePath.getParentDirectory().createDirectoryAndParents();
        if (entry.isDirectory()) {
          filePath.createDirectoryAndParents();
        } else {
          if (entry.isSymbolicLink() || entry.isLink()) {
            PathFragment targetName =
                maybeDeprefixSymlink(
                    toRawBytesString(entry.getLinkName()).getBytes(ISO_8859_1),
                    prefix,
                    descriptor.destinationPath());

            Path resolvedTargetPath =
                (entry.isSymbolicLink()
                        ? filePath.getParentDirectory()
                        : descriptor.destinationPath())
                    .getRelative(targetName);
            if (!targetName.isAbsolute()
                && !resolvedTargetPath.startsWith(descriptor.destinationPath())) {
              throw new IOException(
                  String.format(
                      "Tar entries cannot refer to files outside of their directory: %s has a"
                          + " link %s pointing to %s",
                      descriptor.archivePath(), entryName, targetName));
            }

            if (entry.isSymbolicLink()) {
              symlinks.put(filePath, targetName);
            } else {
              if (filePath.equals(resolvedTargetPath)) {
                // The behavior here is semantically different, depending on whether the underlying
                // filesystem is case-sensitive or case-insensitive. However, it is effectively the
                // same: we drop the link entry.
                // * On a case-sensitive filesystem, this is a hardlink to itself, such as GNU tar
                //   creates when given repeated files. We do nothing since the link already exists.
                // * On a case-insensitive filesystem, we may be extracting a differently-cased
                //   hardlink to the same file (such as when extracting an archive created on a
                //   case-sensitive filesystem). GNU tar, for example, will drop the new link entry.
                //   BSD tar on MacOS X (by default case-insensitive) errors and aborts extraction.
              } else {
                if (filePath.exists()) {
                  filePath.delete();
                }
                FileSystemUtils.createHardLink(filePath, resolvedTargetPath);
              }
            }
          } else {
            try (OutputStream out = filePath.getOutputStream()) {
              ByteStreams.copy(tarStream, out);
            }
            // Ensure that all files are at least user-readable. Some archives contain files that
            // are not, but many other tools are working around this and thus mask these issues.
            filePath.chmod(entry.getMode() | 0400);

            // This can only be done on real files, not links, or it will skip the reader to
            // the next "real" file to try to find the mod time info.
            Date lastModified = entry.getLastModifiedDate();
            filePath.setLastModifiedTime(lastModified.getTime());
          }
        }
        if (Thread.interrupted()) {
          throw new InterruptedException();
        }
      }

      for (Map.Entry<Path, PathFragment> symlink : symlinks.entrySet()) {
        Path linkPath = symlink.getKey();
        if (linkPath.exists()) {
          linkPath.delete();
        }
        FileSystemUtils.ensureSymbolicLink(linkPath, symlink.getValue());
      }

      if (prefix.isPresent() && !foundPrefix) {
        throw new CouldNotFindPrefixException(prefix.get(), availablePrefixes);
      }
    }

    return descriptor.destinationPath();
  }

  /**
   * Returns a string that contains the raw bytes of the given string encoded in ISO-8859-1,
   * assuming that the given string was encoded with either UTF-8 or the special {@link
   * MarkedIso88591Charset}.
   */
  private static String toRawBytesString(String name) {
    // Marked strings are already encoded in ISO-8859-1. Other strings originate from PAX headers
    // and are thus Unicode.
    return MarkedIso88591Charset.getRawBytesStringIfMarked(name)
        .orElseGet(() -> StringEncoding.unicodeToInternal(name));
  }

  /** A provider of {@link MarkedIso88591Charset}s. */
  @AutoService(CharsetProvider.class)
  public static class MarkedIso88591CharsetProvider extends CharsetProvider {
    private static final Charset CHARSET = new MarkedIso88591Charset();

    @Override
    public Iterator<Charset> charsets() {
      // This charset is only meant for internal use within CompressedTarFunction and thus should
      // not be discoverable.
      return Collections.emptyIterator();
    }

    @Override
    public Charset charsetForName(String charsetName) {
      return MarkedIso88591Charset.NAME.equals(charsetName) ? CHARSET : null;
    }
  }

  /**
   * A charset that decodes ISO-8859-1, i.e., produces a String that contains the raw decoded bytes,
   * and appends a marker to the end of the string to indicate that it was decoded with this
   * charset.
   */
  private static class MarkedIso88591Charset extends Charset {
    // The name
    // * must not collide with the name of any other charset.
    // * must not appear in archive entry names by chance.
    // * is internal to CompressedTarFunction.
    // This is best served by a cryptographically random UUID, generated at startup.
    private static final String NAME = UUID.randomUUID().toString();

    private MarkedIso88591Charset() {
      super(NAME, new String[0]);
    }

    public static Optional<String> getRawBytesStringIfMarked(String s) {
      // Check for the marker in all positions as TarArchiveInputStream manipulates the raw name in
      // certain cases (for example, appending a '/' to directory names).
      if (s.contains(NAME)) {
        return Optional.of(s.replaceAll(NAME, ""));
      }
      return Optional.empty();
    }

    @Override
    public CharsetDecoder newDecoder() {
      return new CharsetDecoder(this, 1, 1) {
        @Override
        protected CoderResult decodeLoop(ByteBuffer in, CharBuffer out) {
          // A simple unoptimized ISO-8859-1 decoder.
          while (in.hasRemaining()) {
            if (!out.hasRemaining()) {
              return CoderResult.OVERFLOW;
            }
            out.put((char) (in.get() & 0xFF));
          }
          return CoderResult.UNDERFLOW;
        }

        @Override
        protected CoderResult implFlush(CharBuffer out) {
          // Append the marker to the end of the buffer to indicate that it was decoded with this
          // charset.
          if (out.remaining() < NAME.length()) {
            return CoderResult.OVERFLOW;
          }
          out.put(NAME);
          return CoderResult.UNDERFLOW;
        }
      };
    }

    @Override
    public CharsetEncoder newEncoder() {
      throw new UnsupportedOperationException();
    }

    @Override
    public boolean contains(Charset cs) {
      return false;
    }
  }
}
