/* Copyright (c) 2001-2024, The HSQL Development Group
 * All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are met:
 *
 * Redistributions of source code must retain the above copyright notice, this
 * list of conditions and the following disclaimer.
 *
 * Redistributions in binary form must reproduce the above copyright notice,
 * this list of conditions and the following disclaimer in the documentation
 * and/or other materials provided with the distribution.
 *
 * Neither the name of the HSQL Development Group nor the names of its
 * contributors may be used to endorse or promote products derived from this
 * software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
 * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
 * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
 * ARE DISCLAIMED. IN NO EVENT SHALL HSQL DEVELOPMENT GROUP, HSQLDB.ORG,
 * OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
 * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
 * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
 * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
 * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
 * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */


package org.hsqldb.lib.tar;

import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.io.PipedInputStream;
import java.io.PipedOutputStream;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;

import org.hsqldb.lib.InputStreamInterface;
import org.hsqldb.lib.InputStreamWrapper;
import org.hsqldb.lib.StringUtil;

/**
 * Generates a tar archive from specified Files and InputStreams.
 * Modified by fredt for hot backup
 * @version 2.5.0
 * @since 2.0.0
 *
 * @author Blaine Simpson (blaine dot simpson at admc dot com)
 */
public class TarGenerator {

    protected TarFileOutputStream      archive;
    protected List<TarEntrySupplicant> entryQueue = new ArrayList<>();
    protected long paxThreshold = 0100000000000L;

    // in bytes.  Value here exactly = 8GB.

    /**
     * When data file is this size or greater, in bytes, a
     * Pix Interchange Format 'x' record will be created and used for the file
     * entry.
     * <P>
     * <B>Limitation</B>
     * At this time, PAX is only implemented for entries added as Files,
     * not entries added as Stream.
     * </P>
     *
     * @param paxThreshold long
     */
    public void setPaxThreshold(long paxThreshold) {
        this.paxThreshold = paxThreshold;
    }

    /**
     *
     * @see #setPaxThreshold(long)
     * @return long
     */
    public long getPaxThreshold() {
        return paxThreshold;
    }

    /**
     * Compression is determined directly by the suffix of the file name in
     * the specified path.
     *
     * @param inFile  Absolute or relative (from user.dir) File for
     *                     tar file to be created.  getName() Suffix must
     *                     indicate tar file and may indicate a compression
     *                     method.
     * @param overWrite    True to replace an existing file of same path.
     * @param blocksPerRecord  Null will use default tar value.
     * @throws IOException on access failure
     */
    public TarGenerator(File inFile, boolean overWrite, Integer blocksPerRecord)
            throws IOException {
        File archiveFile = inFile.getAbsoluteFile();

        // Do this so we can be sure .getParent*() is non-null.  (Also allows
        // us to use .getPath() instead of very long .getAbsolutePath() for
        // error messages.
        int compression = TarFileOutputStream.Compression.NO_COMPRESSION;

        if (archiveFile.getName().endsWith(".tgz")
                || archiveFile.getName().endsWith(".tar.gz")) {
            compression = TarFileOutputStream.Compression.GZIP_COMPRESSION;
        } else if (archiveFile.getName().endsWith(".tar")) {

            // purposefully do nothing
        } else {
            throw new IllegalArgumentException(
                RB.unsupported_ext.getString(
                    getClass().getName(), archiveFile.getPath()));
        }

        if (archiveFile.exists()) {
            if (!overWrite) {
                throw new IOException(
                    RB.dest_exists.getString(archiveFile.getPath()));
            }
        } else {
            File parentDir = archiveFile.getParentFile();

            // parentDir will be absolute, since archiveFile is absolute.
            if (parentDir.exists()) {
                if (!parentDir.isDirectory()) {
                    throw new IOException(
                        RB.parent_not_dir.getString(parentDir.getPath()));
                }

                if (!parentDir.canWrite()) {
                    throw new IOException(
                        RB.cant_write_parent.getString(parentDir.getPath()));
                }
            } else {
                if (!parentDir.mkdirs()) {
                    throw new IOException(
                        RB.parent_create_fail.getString(parentDir.getPath()));
                }
            }
        }

        archive = (blocksPerRecord == null)
                  ? new TarFileOutputStream(archiveFile, compression)
                  : new TarFileOutputStream(archiveFile, compression,
                  blocksPerRecord.intValue());

        if ((blocksPerRecord != null) && TarFileOutputStream.debug) {
            System.out.println(
                RB.bpr_write.getString(blocksPerRecord.intValue()));
        }
    }

    public void queueEntry(File file)
            throws FileNotFoundException, TarMalformatException {
        queueEntry(null, file);
    }

    public void queueEntry(String entryPath, File file)
            throws FileNotFoundException, TarMalformatException {
        entryQueue.add(new TarEntrySupplicant(entryPath, file, archive,
                paxThreshold));
    }

    public void queueEntry(String entryPath, InputStreamInterface is)
            throws FileNotFoundException, TarMalformatException {
        entryQueue.add(new TarEntrySupplicant(entryPath, is, archive,
                paxThreshold));
    }

    /**
     * This method does not support Pax Interchange Format, nor data sizes
     * greater than 2G.
     * <P>
     * This limitation may or may not be eliminated in the future.
     * </P>

     * @param entryPath String
     * @param inStream InputStream
     * @param maxBytes int
     * @throws IOException on access failure
     * @throws TarMalformatException if malformed
     */
    public void queueEntry(String entryPath, InputStream inStream, int maxBytes)
            throws IOException, TarMalformatException {
        entryQueue.add(new TarEntrySupplicant(entryPath, inStream, maxBytes,
                '0', archive));
    }

    /**
     * This method does release all of the streams, even if there is a failure.
     *
     * @throws IOException on access failure
     * @throws TarMalformatException if malformed
     */
    public void write() throws IOException, TarMalformatException {
        if (TarFileOutputStream.debug) {
            System.out.println(
                RB.write_queue_report.getString(entryQueue.size()));
        }

        TarEntrySupplicant entry;

        try {
            for (int i = 0; i < entryQueue.size(); i++) {
                System.out.print(Integer.toString(i + 1) + " / "
                                 + entryQueue.size() + ' ');
                entry = entryQueue.get(i);
                System.out.print(entry.getPath() + "... ");
                entry.write();
                archive.assertAtBlockBoundary();
                System.out.println();
            }

            archive.finish();
        } catch (IOException ioe) {
            System.out.println();    // Exception should cause a report

            try {

                // Just release resources from any Entry's input, which may be
                // left open.
                for (TarEntrySupplicant sup : entryQueue) {
                    sup.close();
                }

                archive.close();
            } catch (IOException ne) {

                // Too difficult to report every single error.
                // More important that the user know about the original Exc.
            }

            throw ioe;
        }
    }

    /**
     * Slots for supplicant files and input streams to be added to a Tar
     * archive.
     *
     * @author Blaine Simpson (blaine dot simpson at admc dot com)
     */
    static protected class TarEntrySupplicant {
        static final byte[] HEADER_TEMPLATE =
            TarFileOutputStream.ZERO_BLOCK.clone();
        static Character              swapOutDelim = null;
        static final byte[] ustarBytes   = {
            'u', 's', 't', 'a', 'r'
        };

        static {
            char c = System.getProperty("file.separator").charAt(0);

            if (c != '/') {
                swapOutDelim = Character.valueOf(c);
            }

            try {
                writeField(TarHeaderField.uid, 0L, HEADER_TEMPLATE);
                writeField(TarHeaderField.gid, 0L, HEADER_TEMPLATE);
            } catch (TarMalformatException tme) {

                // This would definitely get caught in Dev env.
                throw new RuntimeException(tme);
            }

            // Setting uid and gid to 0 = root.
            // Misleading, yes.  Anything better we can do?  No.
            int magicStart = TarHeaderField.magic.getStart();

            // UStar magic field
            System.arraycopy(ustarBytes, 0, HEADER_TEMPLATE, magicStart + 0, ustarBytes.length);

            HEADER_TEMPLATE[263] = '0';
            HEADER_TEMPLATE[264] = '0';

            // UStar version field, version = 00
            // This is the field that Gnu Tar desecrates.
        }

        static protected void writeField(TarHeaderField field, String newValue,
                                         byte[] target)
                throws TarMalformatException {
            int    start = field.getStart();
            int    stop  = field.getStop();
            byte[] ba;

            ba = newValue.getBytes(StandardCharsets.ISO_8859_1);

            if (ba.length > stop - start) {
                throw new TarMalformatException(
                    RB.tar_field_toobig.getString(field.toString(), newValue));
            }

            System.arraycopy(ba, 0, target, start + 0, ba.length);
        }

        static protected void clearField(TarHeaderField field, byte[] target) {
            int start = field.getStart();
            int stop  = field.getStop();

            for (int i = start; i < stop; i++) {
                target[i] = 0;
            }
        }

        static protected void writeField(TarHeaderField field, long newValue,
                                         byte[] target)
                throws TarMalformatException {
            TarEntrySupplicant.writeField(
                field,
                TarEntrySupplicant.prePaddedOctalString(
                    newValue, field.getStop() - field.getStart()), target);
        }

        public static String prePaddedOctalString(long val, int width) {
            return StringUtil.toPaddedString(Long.toOctalString(val), width,
                                             '0', false);
        }

        protected byte[] rawHeader = HEADER_TEMPLATE.clone();
        protected String fileMode  = DEFAULT_FILE_MODES;

        // Following fields are always initialized by constructors.
        protected InputStreamInterface inputStream;
        protected String               path;
        protected long                 modTime;
        protected TarFileOutputStream  tarStream;
        protected long                 dataSize;    // In bytes
        protected boolean              paxSized = false;
        protected final long           paxThreshold;

        // (paxSized = true) tells the target entry to NOT set the size header field,
        // to ensure that no tar client accidentally extracts only
        // a portion of the file data.
        // If the client can't read the correct size from the PIF data,
        // we want the client to report that so the user can get a better
        // tar client!
        // Size will only be written to entry's header if paxSized is false.
        public String getPath() {
            return path;
        }

        public long getDataSize() {
            return dataSize;
        }

        /*
         * Internal constructor that validates the entry's path.
         */
        protected TarEntrySupplicant(String path, char typeFlag,
                                     TarFileOutputStream tarStream,
                                     long paxThreshold)
                throws TarMalformatException {
            this.paxThreshold = paxThreshold;

            if (path == null) {
                throw new IllegalArgumentException(
                    RB.missing_supp_path.getString());
            }

            this.path      = (swapOutDelim == null)
                             ? path
                             : path.replace(swapOutDelim.charValue(), '/');
            this.tarStream = tarStream;
            writeField(TarHeaderField.typeflag, typeFlag);

            if ((typeFlag == '\0') || (typeFlag == ' ')) {
                writeField(TarHeaderField.uname,
                           System.getProperty("user.name"), HEADER_TEMPLATE);
                writeField(TarHeaderField.gname, "root", HEADER_TEMPLATE);

                // Setting UNAME and GNAME at the instance level instead of the
                // static template, because record types 'x' and 'g' do not set
                // these fields.
                // POSIX UStar compliance requires that we set "gname" field.
                // It's impossible for use to determine the correct value from
                // Java.  We punt with "root" because (a) it's the only group
                // name
                // we know should exist on every UNIX system, and (b) every tar
                // client gracefully handles it when extractor user does not
                // have privs for the specified group.
            }
        }

        /**
         * This creates a 'x' entry for a 0/\0 entry.
         *
         * @throws IOException on access failure
         * @throws TarMalformatException if malformed
         * @return TarEntrySupplicant
         */
        public TarEntrySupplicant makeXentry()
                throws IOException, TarMalformatException {
            PIFGenerator pif = new PIFGenerator(new File(path));

            pif.addRecord("size", dataSize);

            /*
             *  Really bad to make pseudo-stream just to get a byte array out
             * of it, but it would be a very poor use of development time to
             * re-design this class because the comparative time wasted at
             * runtime will be negligible compared to storing the data entries.
             */
            return new TarEntrySupplicant(
                pif.getName(), new ByteArrayInputStream(pif.toByteArray()),
                pif.size(), 'x', tarStream);
        }

        /**
         * After instantiating a TarEntrySupplicant, the user must either invoke
         * write() or close(), to release system resources on the input
         * File/Stream.
         *
         * @param path String
         * @param file File
         * @param tarStream TarFileOutputStream
         * @param paxThreshold long
         * @throws FileNotFoundException if not found
         * @throws TarMalformatException if malformed
         */
        public TarEntrySupplicant(String path, File file,
                                  TarFileOutputStream tarStream,
                                  long paxThreshold)
                throws FileNotFoundException, TarMalformatException {

            // Must use an expression-embedded ternary here to satisfy compiler
            // that this() call be first statement in constructor.
            this(((path == null)
                  ? file.getPath()
                  : path), '0', tarStream, paxThreshold);

            // Difficult call for '0'.  binary 0 and character '0' both mean
            // regular file.  Binary 0 pre-UStar is probably more portable,
            // but we are writing a valid UStar header, and I doubt anybody's
            // tar implementation would choke on this since there is no
            // outcry of UStar archives failing to work with older tars.
            if (!file.isFile()) {
                throw new IllegalArgumentException(
                    RB.nonfile_entry.getString());
            }

            if (!file.canRead()) {
                throw new IllegalArgumentException(
                    RB.read_denied.getString(file.getAbsolutePath()));
            }

            modTime     = file.lastModified() / 1000L;
            fileMode    = TarEntrySupplicant.getLameMode(file);
            dataSize    = file.length();
            inputStream = new InputStreamWrapper(new FileInputStream(file));
        }

        public TarEntrySupplicant(String path, InputStreamInterface is,
                                  TarFileOutputStream tarStream,
                                  long paxThreshold)
                throws TarMalformatException {

            // Must use an expression-embedded ternary here to satisfy compiler
            // that this() call be first statement in constructor.
            this(path, '0', tarStream, paxThreshold);

            // Difficult call for '0'.  binary 0 and character '0' both mean
            // regular file.  Binary 0 pre-UStar is probably more portable,
            // but we are writing a valid UStar header, and I doubt anybody's
            // tar implementation would choke on this since there is no
            // outcry of UStar archives failing to work with older tars.
            modTime     = System.currentTimeMillis() / 1000L;
            fileMode    = DEFAULT_FILE_MODES;
            inputStream = is;
        }

        /**
         * After instantiating a TarEntrySupplicant, the user must either invoke
         * write() or close(), to release system resources on the input
         * File/Stream.
         * <P>
         * <B>WARNING:</B>
         * Do not use this method unless the quantity of available RAM is
         * sufficient to accommodate the specified maxBytes all at one time.
         * This constructor loads all input from the specified InputStream into
         * RAM before anything is written to disk.
         * </P>
         *
         * @param path String
         * @param origStream InputStream
         * @param maxBytes This method will fail if more than maxBytes bytes
         *                 are supplied on the specified InputStream.
         *                 As the type of this parameter enforces, the max
         *                 size you can request is 2GB.
         * @param typeFlag char
         * @param tarStream TarFileOutputStream
         * @throws IOException on access failure
         * @throws TarMalformatException if malformed
         */
        public TarEntrySupplicant(String path, InputStream origStream,
                                  int maxBytes, char typeFlag,
                                  TarFileOutputStream tarStream)
                throws IOException, TarMalformatException {

            /*
             * If you modify this, make sure to not intermix reading/writing of
             * the PipedInputStream and the PipedOutputStream, or you could
             * cause dead-lock.  Everything is safe if you close the
             * PipedOutputStream before reading the PipedInputStream.
             */
            this(path, typeFlag, tarStream, 0100000000000L);

            if (maxBytes < 1) {
                throw new IllegalArgumentException(RB.read_lt_1.getString());
            }

            int               i;
            PipedOutputStream outPipe = new PipedOutputStream();

            /*
             *  This constructor not available until Java 1.6:
             * inputStream = new PipedInputStream(outPipe, maxBytes);
             */
            try {
                inputStream =
                    new InputStreamWrapper(new PipedInputStream(outPipe));

                while ((i =
                        origStream.read(tarStream.writeBuffer, 0,
                                        tarStream.writeBuffer.length)) > 0) {
                    outPipe.write(tarStream.writeBuffer, 0, i);
                }

                outPipe.flush();    // Do any good on a pipe?
                dataSize = inputStream.available();

                if (TarFileOutputStream.debug) {
                    System.out.println(
                        RB.stream_buffer_report.getString(
                            Long.toString(dataSize)));
                }
            } catch (IOException ioe) {
                close();

                throw ioe;
            } finally {
                try {
                    outPipe.close();
                } finally {
                    outPipe = null;    // Encourage buffer GC
                }
            }

            modTime = new java.util.Date().getTime() / 1000L;
        }

        public void close() throws IOException {
            if (inputStream == null) {
                return;
            }

            try {
                inputStream.close();
            } finally {
                inputStream = null;    // Encourage buffer GC
            }
        }

        protected long headerChecksum() {
            long sum = 0;

            for (int i = 0; i < rawHeader.length; i++) {
                boolean isInRange =
                    ((i >= TarHeaderField.checksum.getStart())
                     && (i < TarHeaderField.checksum.getStop()));

                sum += isInRange
                       ? 32
                       : (255 & rawHeader[i]);

                // We ignore current contents of the checksum field so that
                // this method will continue to work right, even if we later
                // recycle the header or RE-calculate a header.
            }

            return sum;
        }

        protected void clearField(TarHeaderField field) {
            TarEntrySupplicant.clearField(field, rawHeader);
        }

        protected void writeField(TarHeaderField field, String newValue)
                throws TarMalformatException {
            TarEntrySupplicant.writeField(field, newValue, rawHeader);
        }

        protected void writeField(TarHeaderField field, long newValue)
                throws TarMalformatException {
            TarEntrySupplicant.writeField(field, newValue, rawHeader);
        }

        protected void writeField(TarHeaderField field, char c)
                throws TarMalformatException {
            TarEntrySupplicant.writeField(field, Character.toString(c),
                                          rawHeader);
        }

        /**
         * Writes entire entry to this object's tarStream.
         *
         * This method is guaranteed to close the supplicant's input stream.
         *
         * @throws IOException on access failure
         * @throws TarMalformatException if malformed
         */
        public void write() throws IOException, TarMalformatException {
            int i;

            try {

                // normal file streams will return -1 as size limit
                // getSizeLimit() is called just before writing the entry
                long sizeLimit = inputStream.getSizeLimit();

                // special stream with explicit zero limit is not written
                if (sizeLimit == 0) {
                    return;
                }

                // special stream
                if (sizeLimit > 0) {
                    dataSize = sizeLimit;
                }

                if (dataSize >= paxThreshold) {
                    paxSized = true;
                    makeXentry().write();
                    System.out.print("x... ");
                }

                writeField(TarHeaderField.name, path);

                // TODO:  If path.length() > 99, then write a PIF entry with
                // the file path.
                // Don't waste time using the PREFIX header field.
                writeField(TarHeaderField.mode, fileMode);

                if (!paxSized) {
                    writeField(TarHeaderField.size, dataSize);
                }

                writeField(TarHeaderField.mtime, modTime);
                writeField(
                    TarHeaderField.checksum,
                    TarEntrySupplicant.prePaddedOctalString(
                        headerChecksum(), 6) + "\0 ");

                // Silly, but that's what the base header spec calls for.
                tarStream.writeBlock(rawHeader);

                long dataStart = tarStream.getBytesWritten();

                while ((i = inputStream.read(tarStream.writeBuffer)) > 0) {
                    tarStream.write(i);
                }

                if (dataStart + dataSize != tarStream.getBytesWritten()) {
                    throw new IOException(
                        RB.data_changed.getString(
                            Long.toString(dataSize),
                            Long.toString(
                                tarStream.getBytesWritten() - dataStart)));
                }

                tarStream.padCurrentBlock();
            } finally {
                close();
            }
        }

        /**
         * This method is so-named because it only sets the owner privileges,
         * not any "group" or "other" privileges.
         * <P>
         * This is because of Java limitation.
         * Incredibly, with Java 1.6, the API gives you the power to set
         * privileges for "other" (last nibble in file Mode), but no ability
         * to detect the same.
         * </P>
         *
         * @param file File
         * @return String
         */
        static protected String getLameMode(File file) {
            int umod = 0;

            if (file.canExecute()) {
                umod = 1;
            }

            if (file.canWrite()) {
                umod += 2;
            }

            if (file.canRead()) {
                umod += 4;
            }

            return "0" + umod + "00";

            // Conservative since Java gives us no way to determine group or
            // other privileges on a file, and this file may contain passwords.
        }

        public static final String DEFAULT_FILE_MODES = "600";

        // Be conservative, because these files contain passwords
    }
}
