/*
 * Copyright 2019 Bloomberg Finance LP
 *
 * 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.
 */

#include "ThreadPool.h"
#include <buildboxcommon_hardlinkstager.h>

#include <buildboxcommon_fslocalcas.h>

#include <buildboxcommon_digestgenerator.h>
#include <buildboxcommon_protos.h>
#include <buildboxcommon_temporarydirectory.h>

#include <buildboxcommon_metricnames.h>
#include <buildboxcommonmetrics_countingmetricvalue.h>
#include <buildboxcommonmetrics_testingutils.h>

#include <dirent.h>
#include <fstream>

#include <gtest/gtest.h>
#include <memory>

#if __APPLE__
#define st_mtim st_mtimespec
#define st_atim st_atimespec
#endif

using namespace buildboxcommon;

namespace {
const auto digestFunctionInitializer = []() {
    DigestGenerator::init();
    return 0;
}();
} // namespace

class FileStagerFixture : public ::testing::Test {
  protected:
    FileStagerFixture()
        : cas_root_directory(), cas(cas_root_directory.name()),
          stager(&cas, std::make_shared<ThreadPool>(2, ""))
    {
        // Adding two files to the cas...
        file_contents["file1.sh"] = "file1Contents...";
        file_digests["file1.sh"] =
            DigestGenerator::hash(file_contents["file1.sh"]);
        cas.writeBlob(file_digests["file1.sh"], file_contents["file1.sh"]);

        file_contents["file2.c"] = "file2: [...data...]";
        file_digests["file2.c"] =
            DigestGenerator::hash(file_contents["file2.c"]);
        cas.writeBlob(file_digests["file2.c"], file_contents["file2.c"]);

        // ... and creating a directory structure that contains them:
        createDirectoryTree();
    }

    void createDirectoryTree()
    {
        /*
         *  root/
         *  |-- file1.sh*
         *  |-- link -> subdir1/file2.c
         *  |-- subdir1/
         *           |-- file2.c
         */

        // Creating `root/`:
        Directory root_directory;

        // Adding a file to it:
        FileNode *f1 = root_directory.add_files();
        f1->set_name("file1.sh");
        f1->set_is_executable(true);
        f1->mutable_digest()->CopyFrom(file_digests["file1.sh"]);

        // Adding symlink (`link` -> `subdir1/file2.c`)
        SymlinkNode *link = root_directory.add_symlinks();
        link->set_name("link");
        link->set_target("subdir1/file2.c");

        // Creating `subdir1/`:
        Directory subdirectory;

        // Adding `subdir1/file2.c`:
        FileNode *f2 = subdirectory.add_files();
        f2->set_name("file2.c");
        f2->mutable_digest()->CopyFrom(file_digests["file2.c"]);

        google::protobuf::Timestamp gtime;
        google::protobuf::util::TimeUtil::FromString(
            "2000-01-12T00:00:00.012300Z", &gtime);
        auto properties2 = f2->mutable_node_properties();
        properties2->mutable_mtime()->CopyFrom(gtime);

        // Adding `subdir1/` to root:
        DirectoryNode *d1 = root_directory.add_directories();
        d1->set_name("subdir1");

        const auto serialized_subdirectory = subdirectory.SerializeAsString();
        const auto subdirectory_digest =
            DigestGenerator::hash(serialized_subdirectory);
        d1->mutable_digest()->CopyFrom(subdirectory_digest);

        // Creating `readonly/`:
        Directory readonly_subdirectory;
        auto property =
            readonly_subdirectory.mutable_node_properties()->add_properties();
        property->set_name("SubtreeReadOnly");
        property->set_value("true");

        // Adding `readonly/` to root:
        DirectoryNode *d2 = root_directory.add_directories();
        d2->set_name("readonly");

        const auto serialized_readonly =
            readonly_subdirectory.SerializeAsString();
        const auto readonly_digest =
            DigestGenerator::hash(serialized_readonly);
        d2->mutable_digest()->CopyFrom(readonly_digest);

        const auto serialized_root_directory =
            root_directory.SerializeAsString();
        const auto root_directory_digest =
            DigestGenerator::hash(serialized_root_directory);

        // Storing the three Directory protos...
        cas.writeBlob(root_directory_digest, serialized_root_directory);
        cas.writeBlob(subdirectory_digest, serialized_subdirectory);
        cas.writeBlob(readonly_digest, serialized_readonly);

        this->root_directory_digest = root_directory_digest;
    }

    Digest root_directory_digest;

    std::map<std::string, Digest> file_digests;
    std::map<std::string, std::string> file_contents;

    TemporaryDirectory cas_root_directory;
    FsLocalCas cas;
    HardLinkStager stager;

    TemporaryDirectory stage_directory;
};

TEST_F(FileStagerFixture, StageDirectoryNotEmptyThrows)
{
    const std::string stage_directory_path(stage_directory.name());

    const auto file_path = stage_directory_path + "/file.txt";
    std::ofstream f(file_path.c_str());
    f.close();
    ASSERT_TRUE(f.good());

    ASSERT_THROW(stager.stage(root_directory_digest, stage_directory_path),
                 std::invalid_argument);
}

void assertStagedDirectoryContentsMatch(
    const std::string &stage_path,
    std::map<std::string, std::string> &file_contents)
{
    DIR *dir = opendir(stage_path.c_str());
    ASSERT_NE(dir, nullptr);

    // The staged directory contains the relevant contents and nothing more:
    struct dirent *entry = nullptr;
    errno = 0;
    while ((entry = readdir(dir)) != nullptr) {
        const auto entry_name = std::string(entry->d_name);

        if (entry_name == "." || entry_name == "..") {
            continue;
        }

        if (entry_name == "file1.sh"
#ifdef _DIRENT_HAVE_D_TYPE
            && (entry->d_type == DT_REG)
#endif
        ) {
            continue;
        }

        if ((entry_name == "subdir1" || entry_name == "readonly")
#ifdef _DIRENT_HAVE_D_TYPE
            && entry->d_type == DT_DIR
#endif
        ) {
            continue;
        }

        if (entry_name == "link"
#ifdef _DIRENT_HAVE_D_TYPE
            && entry->d_type == DT_LNK
#endif
        ) {
            continue;
        }

        ASSERT_TRUE(false);
    }

    // `readdir()` did not fail; we scanned the whole stage directory:
    ASSERT_EQ(errno, 0);

    /*
     * file1.c
     */
    // Its contents match:
    const std::string file1_stage_path = stage_path + "/file1.sh";
    std::ifstream f1(file1_stage_path, std::fstream::binary);
    ASSERT_TRUE(f1.good());

    std::stringstream file1_contents;
    file1_contents << f1.rdbuf();
    ASSERT_TRUE(f1.good());
    ASSERT_EQ(file1_contents.str(), file_contents["file1.sh"]);

    // And it also has +x:
    struct stat file1_stat = {};
    ASSERT_EQ(stat(file1_stage_path.c_str(), &file1_stat), 0);
    ASSERT_TRUE(file1_stat.st_mode & S_IXUSR);

    /*
     * subdir/file2.c
     */
    // Its contents match:
    const std::string file2_stage_path = stage_path + "/subdir1/file2.c";
    std::ifstream f2(file2_stage_path, std::fstream::binary);
    std::stringstream file2_contents;
    file2_contents << f2.rdbuf();
    ASSERT_EQ(file2_contents.str(), file_contents["file2.c"]);

    // But it is *not* executable:
    struct stat file2_stat = {};
    ASSERT_EQ(stat(file2_stage_path.c_str(), &file2_stat), 0);
    ASSERT_FALSE(file2_stat.st_mode & S_IXUSR);

    // But it has the expected mtime:
    ASSERT_EQ(file2_stat.st_mtim.tv_sec, 947635200);
    // FIXME: runner fs may not support sub-second precision
    const long nsec = file2_stat.st_mtim.tv_nsec;
    ASSERT_TRUE((nsec == 12300000) || (nsec == 0));

    /*
     * link -> subdir/file2.c
     */
    const std::string link_stage_path = stage_path + "/link";
    std::ifstream f3(link_stage_path, std::fstream::binary);
    std::stringstream link_target_contents;
    link_target_contents << f3.rdbuf();
    ASSERT_EQ(link_target_contents.str(), file_contents["file2.c"]);

    /* Check that the readonly subdirectory is read-only for other users */
    const std::string readonly_stage_path = stage_path + "/readonly";
    struct stat readonly_stat = {};
    ASSERT_EQ(stat(readonly_stage_path.c_str(), &readonly_stat), 0);
    ASSERT_EQ(readonly_stat.st_mode & (S_IWGRP | S_IWOTH), 0);

    // The metrics containing the number of items that were staged were
    // published:
    ASSERT_TRUE(buildboxcommonmetrics::allCollectedByNameWithValues<
                buildboxcommonmetrics::CountingMetricValue>({
        {CommonMetricNames::COUNTER_NUM_HARDLINK_STAGER_STAGED_FILES,
         buildboxcommonmetrics::CountingMetricValue(2)},
        {CommonMetricNames::COUNTER_NUM_HARDLINK_STAGER_STAGED_DIRECTORIES,
         buildboxcommonmetrics::CountingMetricValue(2)},
        {CommonMetricNames::COUNTER_NUM_HARDLINK_STAGER_STAGED_SYMLINKS,
         buildboxcommonmetrics::CountingMetricValue(1)},
    }));

    closedir(dir);
}

mode_t pathPermissions(const std::string &path)
{
    struct stat s = {};
    if (stat(path.c_str(), &s) == 0) {
        return s.st_mode & 0777;
    }

    throw std::system_error(errno, std::system_category(),
                            "Error calling stat(" + path + ")");
}

TEST_F(FileStagerFixture, Stage)
{
    const std::string stage_path = stage_directory.name();

    const auto staged_directory =
        stager.stage(root_directory_digest, stage_path);

    // Checking top directory permissions:
    ASSERT_EQ(pathPermissions(stage_path), 0777);

    assertStagedDirectoryContentsMatch(stage_path, file_contents);

    ASSERT_EQ(pathPermissions(stage_path + "/file1.sh"), 0555);
    ASSERT_EQ(pathPermissions(stage_path + "/subdir1/file2.c"), 0444);
}

TEST_F(FileStagerFixture, StageRootNotPresent)
{
    const std::string stage_path = stage_directory.name();

    Digest missing_digest;
    missing_digest.set_hash("digest");
    missing_digest.set_size_bytes(1234);

    ASSERT_FALSE(cas.hasBlob(missing_digest));

    ASSERT_THROW(stager.stage(missing_digest, stage_path),
                 std::invalid_argument);
}

TEST_F(FileStagerFixture, StageIntoNonExistingPath)
{
    const std::string stage_path =
        std::string(stage_directory.name()) + "/stage-here";
    ASSERT_FALSE(FileUtils::isDirectory(stage_path.c_str()));

    const auto staged_directory =
        stager.stage(root_directory_digest, stage_path);
    ASSERT_EQ(pathPermissions(stage_path), 0777);

    assertStagedDirectoryContentsMatch(stage_path, file_contents);
}

TEST_F(FileStagerFixture, StageAccessByOwner)
{
    ProcessCredentials access_credentials = {};
    access_credentials.uid = geteuid();

    const std::string stage_path = stage_directory.name();

    const auto staged_directory =
        stager.stage(root_directory_digest, stage_path, access_credentials);

    assertStagedDirectoryContentsMatch(stage_path, file_contents);

    ASSERT_EQ(pathPermissions(stage_path + "/file1.sh"), 0755);
    ASSERT_EQ(pathPermissions(stage_path + "/subdir1/file2.c"), 0644);
}

TEST_F(FileStagerFixture, StageUnixModeGroupWrite)
{
    // Create a directory where some files have group write permission
    Directory sharedRoot;

    // Creating `file1.sh*`
    FileNode *f1 = sharedRoot.add_files();
    f1->set_name("file1.sh");
    f1->set_is_executable(true);
    f1->mutable_digest()->CopyFrom(file_digests["file1.sh"]);
    f1->mutable_node_properties()->mutable_unix_mode()->set_value(0775);
    // Creating `notshared.sh` without setting unix_mode
    FileNode *fNotShared = sharedRoot.add_files();
    fNotShared->set_name("notshared.sh");
    fNotShared->set_is_executable(true);
    fNotShared->mutable_digest()->CopyFrom(file_digests["file1.sh"]);

    // Creating `subdir1/`:
    Directory subdirectory;
    // Adding `subdir1/file2.c`:
    FileNode *f2 = subdirectory.add_files();
    f2->set_name("file2.c");
    f2->mutable_digest()->CopyFrom(file_digests["file2.c"]);
    f2->mutable_node_properties()->mutable_unix_mode()->set_value(0464);
    // Add /subdir1 to root
    DirectoryNode *d1 = sharedRoot.add_directories();
    d1->set_name("subdir1");
    const auto subdirectoryDigest =
        DigestGenerator::hash(subdirectory.SerializeAsString());
    d1->mutable_digest()->CopyFrom(subdirectoryDigest);
    cas.writeBlob(DigestGenerator::hash(subdirectory.SerializeAsString()),
                  subdirectory.SerializeAsString());

    const Digest sharedRootDigest =
        DigestGenerator::hash(sharedRoot.SerializeAsString());
    cas.writeBlob(sharedRootDigest, sharedRoot.SerializeAsString());

    ProcessCredentials accessCredential = {};
    accessCredential.uid = static_cast<uid_t>(-1);
    accessCredential.gid = getegid();

    const std::string stage_path = stage_directory.name();
    const auto staged_directory =
        stager.stage(sharedRootDigest, stage_path, accessCredential);

    // g+w
    ASSERT_EQ(pathPermissions(stage_path + "/file1.sh"), 0775);
    ASSERT_EQ(pathPermissions(stage_path + "/subdir1/file2.c"), 0464);
    // readonly
    ASSERT_EQ(pathPermissions(stage_path + "/notshared.sh"), 0555);
}

TEST_F(FileStagerFixture, StageUserchrootDirectory)
{
    // Creating an empty chroot.
    // (The `unix_mode` value in the root signals the stager to use special
    // permissions for it.)
    Directory chroot_root_directory;
    chroot_root_directory.mutable_node_properties()
        ->mutable_unix_mode()
        ->set_value(0755);

    const std::string serialized_chroot_root_directory =
        chroot_root_directory.SerializeAsString();
    const Digest chroot_root_directory_digest =
        DigestGenerator::hash(serialized_chroot_root_directory);

    ASSERT_TRUE(cas.writeBlob(chroot_root_directory_digest,
                              serialized_chroot_root_directory));

    const std::string stage_path = stage_directory.name();
    const auto staged_directory =
        stager.stage(chroot_root_directory_digest, stage_path);

    ASSERT_EQ(pathPermissions(stage_path), 0755);
}

TEST(HardLinkStagedDirectoryTest,
     HardLinkStagedDirectoryUnstageClearingStageDir)
{
    TemporaryDirectory stage_dir;
    TemporaryFile file(stage_dir.name(), "test-file");
    ASSERT_FALSE(FileUtils::directoryIsEmpty(stage_dir.name()));

    {
        HardLinkStager::HardLinkStagedDirectory(stage_dir.name());
    }

    // The destructor unstaged but did not delete the root directory; only
    // cleared its contents:
    ASSERT_TRUE(FileUtils::isDirectory(stage_dir.name()));
    ASSERT_TRUE(FileUtils::directoryIsEmpty(stage_dir.name()));
}
