// Copyright 2014 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.vfs;

import static com.google.common.truth.Truth.assertThat;
import static java.nio.charset.StandardCharsets.UTF_8;
import static org.junit.Assert.assertArrayEquals;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;

import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Iterables;
import com.google.devtools.build.lib.util.BlazeClock;
import com.google.devtools.build.lib.util.Clock;
import com.google.devtools.build.lib.vfs.inmemoryfs.InMemoryFileSystem;

import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.JUnit4;

import java.io.FileNotFoundException;
import java.io.InputStream;
import java.io.OutputStream;

/**
 * Tests for the UnionFileSystem, both of generic FileSystem functionality
 * (inherited) and tests of UnionFileSystem-specific behavior.
 */
@RunWith(JUnit4.class)
public class UnionFileSystemTest extends SymlinkAwareFileSystemTest {
  private XAttrInMemoryFs inDelegate;
  private XAttrInMemoryFs outDelegate;
  private XAttrInMemoryFs defaultDelegate;
  private UnionFileSystem unionfs;

  private static final String XATTR_VAL = "SOME_XATTR_VAL";
  private static final String XATTR_KEY = "SOME_XATTR_KEY";

  private void setupDelegateFileSystems() {
    inDelegate = new XAttrInMemoryFs(BlazeClock.instance());
    outDelegate = new XAttrInMemoryFs(BlazeClock.instance());
    defaultDelegate = new XAttrInMemoryFs(BlazeClock.instance());

    unionfs = createDefaultUnionFileSystem();
  }

  private UnionFileSystem createDefaultUnionFileSystem() {
    return createDefaultUnionFileSystem(false);
  }

  private UnionFileSystem createDefaultUnionFileSystem(boolean readOnly) {
    return new UnionFileSystem(ImmutableMap.<PathFragment, FileSystem>of(
        new PathFragment("/in"), inDelegate,
        new PathFragment("/out"), outDelegate),
        defaultDelegate, readOnly);
  }

  @Override
  protected FileSystem getFreshFileSystem() {
    // Executed with each new test because it is called by super.setUp().
    setupDelegateFileSystems();
    return unionfs;
  }

  @Override
  public void destroyFileSystem(FileSystem fileSystem) {
    // Nothing.
  }

  // Tests of UnionFileSystem-specific behavior below.

  @Test
  public void testBasicDelegation() throws Exception {
    unionfs = createDefaultUnionFileSystem();
    Path fooPath = unionfs.getPath("/foo");
    Path inPath = unionfs.getPath("/in");
    Path outPath = unionfs.getPath("/out/in.txt");
    assertSame(inDelegate, unionfs.getDelegate(inPath));
    assertSame(outDelegate, unionfs.getDelegate(outPath));
    assertSame(defaultDelegate, unionfs.getDelegate(fooPath));
  }

  @Test
  public void testBasicXattr() throws Exception {
    Path fooPath = unionfs.getPath("/foo");
    Path inPath = unionfs.getPath("/in");
    Path outPath = unionfs.getPath("/out/in.txt");

    assertArrayEquals(XATTR_VAL.getBytes(UTF_8), inPath.getxattr(XATTR_KEY));
    assertArrayEquals(XATTR_VAL.getBytes(UTF_8), outPath.getxattr(XATTR_KEY));
    assertArrayEquals(XATTR_VAL.getBytes(UTF_8), fooPath.getxattr(XATTR_KEY));
    assertNull(inPath.getxattr("not_key"));
    assertNull(outPath.getxattr("not_key"));
    assertNull(fooPath.getxattr("not_key"));
  }

  @Test
  public void testDefaultFileSystemRequired() throws Exception {
    try {
      new UnionFileSystem(ImmutableMap.<PathFragment, FileSystem>of(), null);
      fail("Able to create a UnionFileSystem with no default!");
    } catch (NullPointerException expected) {
      // OK - should fail in this case.
    }
  }

  // Check for appropriate registration and lookup of delegate filesystems based
  // on path prefixes, including non-canonical paths.
  @Test
  public void testPrefixDelegation() throws Exception {
    unionfs = new UnionFileSystem(ImmutableMap.<PathFragment, FileSystem>of(
              new PathFragment("/foo"), inDelegate,
              new PathFragment("/foo/bar"), outDelegate), defaultDelegate);

    assertSame(inDelegate, unionfs.getDelegate(unionfs.getPath("/foo/foo.txt")));
    assertSame(outDelegate, unionfs.getDelegate(unionfs.getPath("/foo/bar/foo.txt")));
    assertSame(inDelegate, unionfs.getDelegate(unionfs.getPath("/foo/bar/../foo.txt")));
    assertSame(defaultDelegate, unionfs.getDelegate(unionfs.getPath("/bar/foo.txt")));
    assertSame(defaultDelegate, unionfs.getDelegate(unionfs.getPath("/foo/bar/../..")));
  }

  // Checks that files cannot be modified when the filesystem is created
  // read-only, even if the delegate filesystems are read/write.
  @Test
  public void testModificationFlag() throws Exception {
    assertTrue(unionfs.supportsModifications());
    Path outPath = unionfs.getPath("/out/foo.txt");
    assertTrue(unionfs.createDirectory(outPath.getParentDirectory()));
    OutputStream outFile = unionfs.getOutputStream(outPath);
    outFile.write('b');
    outFile.close();

    unionfs.setExecutable(outPath, true);

    // Note that this does not destroy the underlying filesystems;
    // UnionFileSystem is just a view.
    unionfs = createDefaultUnionFileSystem(true);
    assertFalse(unionfs.supportsModifications());

    InputStream outFileInput = unionfs.getInputStream(outPath);
    int outFileByte = outFileInput.read();
    outFileInput.close();
    assertEquals('b', outFileByte);

    assertTrue(unionfs.isExecutable(outPath));

    // Modifying files through the unionfs isn't permitted, even if the
    // delegates are read/write.
    try {
      unionfs.setExecutable(outPath, false);
      fail("Modification to a read-only UnionFileSystem succeeded.");
    } catch (UnsupportedOperationException expected) {
      // OK - should fail.
    }
  }

  // Checks that roots of delegate filesystems are created outside of the
  // delegate filesystems; i.e. they can be seen from the filesystem of the parent.
  @Test
  public void testDelegateRootDirectoryCreation() throws Exception {
    Path foo = unionfs.getPath("/foo");
    Path bar = unionfs.getPath("/bar");
    Path out = unionfs.getPath("/out");
    assertTrue(unionfs.createDirectory(foo));
    assertTrue(unionfs.createDirectory(bar));
    assertTrue(unionfs.createDirectory(out));
    Path outFile = unionfs.getPath("/out/in");
    FileSystemUtils.writeContentAsLatin1(outFile, "Out");

    // FileSystemTest.setUp() silently creates the test root on the filesystem...
    Path testDirUnderRoot = unionfs.getPath(workingDir.asFragment().subFragment(0, 1));
    assertThat(unionfs.getDirectoryEntries(unionfs.getRootDirectory())).containsExactly(foo, bar,
        out, testDirUnderRoot);
    assertThat(unionfs.getDirectoryEntries(out)).containsExactly(outFile);

    assertSame(unionfs.getDelegate(foo), defaultDelegate);
    assertEquals(foo.asFragment(), unionfs.adjustPath(foo, defaultDelegate).asFragment());
    assertSame(unionfs.getDelegate(bar), defaultDelegate);
    assertSame(unionfs.getDelegate(outFile), outDelegate);
    assertSame(unionfs.getDelegate(out), outDelegate);

    // As a fragment (i.e. without filesystem or root info), the path name should be preserved.
    assertEquals(outFile.asFragment(), unionfs.adjustPath(outFile, outDelegate).asFragment());
  }

  // Ensure that the right filesystem is still chosen when paths contain "..".
  @Test
  public void testDelegationOfUpLevelReferences() throws Exception {
    assertSame(defaultDelegate, unionfs.getDelegate(unionfs.getPath("/in/../foo.txt")));
    assertSame(inDelegate, unionfs.getDelegate(unionfs.getPath("/out/../in")));
    assertSame(outDelegate, unionfs.getDelegate(unionfs.getPath("/out/../in/../out/foo.txt")));
    assertSame(inDelegate, unionfs.getDelegate(unionfs.getPath("/in/./foo.txt")));
  }

  // Basic *explicit* cross-filesystem symlink check.
  // Note: This does not work implicitly yet, as the next test illustrates.
  @Test
  public void testCrossDeviceSymlinks() throws Exception {
    assertTrue(unionfs.createDirectory(unionfs.getPath("/out")));

    // Create an "/in" directory directly on the output delegate to bypass the
    // UnionFileSystem's mapping.
    assertTrue(inDelegate.getPath("/in").createDirectory());
    OutputStream outStream = inDelegate.getPath("/in/bar.txt").getOutputStream();
    outStream.write('i');
    outStream.close();

    Path outFoo = unionfs.getPath("/out/foo");
    unionfs.createSymbolicLink(outFoo, new PathFragment("../in/bar.txt"));
    assertTrue(unionfs.stat(outFoo, false).isSymbolicLink());

    try {
      unionfs.stat(outFoo, true).isFile();
      fail("Stat on cross-device symlink succeeded!");
    } catch (FileNotFoundException expected) {
      // OK
    }

    Path resolved = unionfs.resolveSymbolicLinks(outFoo);
    assertSame(unionfs, resolved.getFileSystem());
    InputStream barInput = resolved.getInputStream();
    int barChar = barInput.read();
    barInput.close();
    assertEquals('i', barChar);
  }

  @Test
  public void testNoDelegateLeakage() throws Exception {
    assertSame(unionfs, unionfs.getPath("/in/foo.txt").getFileSystem());
    assertSame(unionfs, unionfs.getPath("/in/foo/bar").getParentDirectory().getFileSystem());
    unionfs.createDirectory(unionfs.getPath("/out"));
    unionfs.createDirectory(unionfs.getPath("/out/foo"));
    unionfs.createDirectory(unionfs.getPath("/out/foo/bar"));
    assertSame(unionfs, Iterables.getOnlyElement(unionfs.getDirectoryEntries(
        unionfs.getPath("/out/foo"))).getParentDirectory().getFileSystem());
  }

  // Prefix mappings can apply to files starting with a prefix within a directory.
  @Test
  public void testWithinDirectoryMapping() throws Exception {
    unionfs = new UnionFileSystem(ImmutableMap.<PathFragment, FileSystem>of(
        new PathFragment("/fruit/a"), inDelegate,
        new PathFragment("/fruit/b"), outDelegate), defaultDelegate);
    assertTrue(unionfs.createDirectory(unionfs.getPath("/fruit")));
    assertTrue(defaultDelegate.getPath("/fruit").isDirectory());
    assertTrue(inDelegate.getPath("/fruit").createDirectory());
    assertTrue(outDelegate.getPath("/fruit").createDirectory());

    Path apple = unionfs.getPath("/fruit/apple");
    Path banana = unionfs.getPath("/fruit/banana");
    Path cherry = unionfs.getPath("/fruit/cherry");
    unionfs.createDirectory(apple);
    unionfs.createDirectory(banana);
    assertSame(inDelegate, unionfs.getDelegate(apple));
    assertSame(outDelegate, unionfs.getDelegate(banana));
    assertSame(defaultDelegate, unionfs.getDelegate(cherry));

    FileSystemUtils.writeContentAsLatin1(apple.getRelative("table"), "penny");
    FileSystemUtils.writeContentAsLatin1(banana.getRelative("nana"), "nanana");
    FileSystemUtils.writeContentAsLatin1(cherry, "garcia");

    assertEquals("penny", new String(
        FileSystemUtils.readContentAsLatin1(inDelegate.getPath("/fruit/apple/table"))));
    assertEquals("nanana", new String(
        FileSystemUtils.readContentAsLatin1(outDelegate.getPath("/fruit/banana/nana"))));
    assertEquals("garcia", new String(
        FileSystemUtils.readContentAsLatin1(defaultDelegate.getPath("/fruit/cherry"))));
  }

  // Write using the VFS through a UnionFileSystem and check that the file can
  // be read back in the same location using standard Java IO.
  // There is a similar test in UnixFileSystem, but this is essential to ensure
  // that paths aren't being remapped in some nasty way on the underlying FS.
  @Test
  public void testDelegateOperationsReflectOnLocalFilesystem() throws Exception {
    unionfs = new UnionFileSystem(ImmutableMap.<PathFragment, FileSystem>of(
        workingDir.getParentDirectory().asFragment(), new UnixFileSystem()),
        defaultDelegate, false);
    // This is a child of the current tmpdir, and doesn't exist on its own.
    // It would be created in setup(), but of course, that didn't use a UnixFileSystem.
    unionfs.createDirectory(workingDir);
    Path testFile = unionfs.getPath(workingDir.getRelative("test_file").asFragment());
    assertTrue(testFile.asFragment().startsWith(workingDir.asFragment()));
    String testString = "This is a test file";
    FileSystemUtils.writeContentAsLatin1(testFile, testString);
    try {
      assertEquals(testString, new String(FileSystemUtils.readContentAsLatin1(testFile)));
    } finally {
      testFile.delete();
      assertTrue(unionfs.delete(workingDir));
    }
  }

  // Regression test for [UnionFS: Directory creation across mapping fails.]
  @Test
  public void testCreateParentsAcrossMapping() throws Exception {
    unionfs = new UnionFileSystem(ImmutableMap.<PathFragment, FileSystem>of(
        new PathFragment("/out/dir"), outDelegate), defaultDelegate, false);
    Path outDir = unionfs.getPath("/out/dir/biz/bang");
    FileSystemUtils.createDirectoryAndParents(outDir);
    assertTrue(outDir.isDirectory());
  }

  private static class XAttrInMemoryFs extends InMemoryFileSystem {
    public XAttrInMemoryFs(Clock clock) {
      super(clock);
    }

    @Override
    protected byte[] getxattr(Path path, String name) {
      assertSame(this, path.getFileSystem());
      return (name.equals(XATTR_KEY)) ? XATTR_VAL.getBytes(UTF_8) : null;
    }
  }
}
