/**
 * 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.hadoop.crypto.key;

import java.io.File;
import java.io.IOException;
import java.net.URI;
import java.util.List;

import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.crypto.key.KeyProvider.KeyVersion;
import org.apache.hadoop.fs.FileStatus;
import org.apache.hadoop.fs.FileSystem;
import org.apache.hadoop.fs.FileSystemTestHelper;
import org.apache.hadoop.fs.Path;
import org.apache.hadoop.fs.permission.FsPermission;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.security.Credentials;
import org.apache.hadoop.security.ProviderUtils;
import org.apache.hadoop.security.UserGroupInformation;
import org.apache.hadoop.test.GenericTestUtils;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;

public class TestKeyProviderFactory {

  private FileSystemTestHelper fsHelper;
  private File testRootDir;

  @BeforeEach
  public void setup() {
    fsHelper = new FileSystemTestHelper();
    String testRoot = fsHelper.getTestRootDir();
    testRootDir = new File(testRoot).getAbsoluteFile();
  }

  @Test
  public void testFactory() throws Exception {
    Configuration conf = new Configuration();
    final String userUri = UserProvider.SCHEME_NAME + ":///";
    final Path jksPath = new Path(testRootDir.toString(), "test.jks");
    final String jksUri = JavaKeyStoreProvider.SCHEME_NAME +
        "://file" + jksPath.toUri().toString();
    conf.set(KeyProviderFactory.KEY_PROVIDER_PATH,
        userUri + "," + jksUri);
    List<KeyProvider> providers = KeyProviderFactory.getProviders(conf);
    assertEquals(2, providers.size());
    assertEquals(UserProvider.class, providers.get(0).getClass());
    assertEquals(JavaKeyStoreProvider.class, providers.get(1).getClass());
    assertEquals(userUri, providers.get(0).toString());
    assertEquals(jksUri, providers.get(1).toString());
  }

  @Test
  public void testFactoryErrors() throws Exception {
    Configuration conf = new Configuration();
    conf.set(KeyProviderFactory.KEY_PROVIDER_PATH, "unknown:///");
    try {
      List<KeyProvider> providers = KeyProviderFactory.getProviders(conf);
      assertTrue(false, "should throw!");
    } catch (IOException e) {
      assertEquals("No KeyProviderFactory for unknown:/// in " +
          KeyProviderFactory.KEY_PROVIDER_PATH,
          e.getMessage());
    }
  }

  @Test
  public void testUriErrors() throws Exception {
    Configuration conf = new Configuration();
    conf.set(KeyProviderFactory.KEY_PROVIDER_PATH, "unkn@own:/x/y");
    try {
      List<KeyProvider> providers = KeyProviderFactory.getProviders(conf);
      assertTrue(false, "should throw!");
    } catch (IOException e) {
      assertEquals("Bad configuration of " +
          KeyProviderFactory.KEY_PROVIDER_PATH +
          " at unkn@own:/x/y", e.getMessage());
    }
  }

  static void checkSpecificProvider(Configuration conf,
                                   String ourUrl) throws Exception {
    KeyProvider provider = KeyProviderFactory.getProviders(conf).get(0);
    byte[] key1 = new byte[16];
    byte[] key2 = new byte[16];
    byte[] key3 = new byte[16];
    for(int i =0; i < key1.length; ++i) {
      key1[i] = (byte) i;
      key2[i] = (byte) (i * 2);
      key3[i] = (byte) (i * 3);
    }
    // ensure that we get nulls when the key isn't there
    assertEquals(null, provider.getKeyVersion("no-such-key"));
    assertEquals(null, provider.getMetadata("key"));
    // create a new key
    try {
      provider.createKey("key3", key3, KeyProvider.options(conf));
    } catch (Exception e) {
      e.printStackTrace();
      throw e;
    }
    // check the metadata for key3
    KeyProvider.Metadata meta = provider.getMetadata("key3");
    assertEquals(KeyProvider.DEFAULT_CIPHER, meta.getCipher());
    assertEquals(KeyProvider.DEFAULT_BITLENGTH, meta.getBitLength());
    assertEquals(1, meta.getVersions());
    // make sure we get back the right key
    assertArrayEquals(key3, provider.getCurrentKey("key3").getMaterial());
    assertEquals("key3@0", provider.getCurrentKey("key3").getVersionName());
    // try recreating key3
    try {
      provider.createKey("key3", key3, KeyProvider.options(conf));
      assertTrue(false, "should throw");
    } catch (IOException e) {
      assertEquals("Key key3 already exists in " + ourUrl, e.getMessage());
    }
    provider.deleteKey("key3");
    try {
      provider.deleteKey("key3");
      assertTrue(false, "should throw");
    } catch (IOException e) {
      assertEquals("Key key3 does not exist in " + ourUrl, e.getMessage());
    }
    provider.createKey("key3", key3, KeyProvider.options(conf));
    try {
      provider.createKey("key4", key3,
          KeyProvider.options(conf).setBitLength(8));
      assertTrue(false, "should throw");
    } catch (IOException e) {
      assertEquals("Wrong key length. Required 8, but got 128", e.getMessage());
    }
    provider.createKey("key4", new byte[]{1},
        KeyProvider.options(conf).setBitLength(8));
    provider.rollNewVersion("key4", new byte[]{2});
    meta = provider.getMetadata("key4");
    assertEquals(2, meta.getVersions());
    assertArrayEquals(new byte[]{2},
        provider.getCurrentKey("key4").getMaterial());
    assertArrayEquals(new byte[]{1},
        provider.getKeyVersion("key4@0").getMaterial());
    assertEquals("key4@1", provider.getCurrentKey("key4").getVersionName());
    try {
      provider.rollNewVersion("key4", key1);
      assertTrue(false, "should throw");
    } catch (IOException e) {
      assertEquals("Wrong key length. Required 8, but got 128", e.getMessage());
    }
    try {
      provider.rollNewVersion("no-such-key", key1);
      assertTrue(false, "should throw");
    } catch (IOException e) {
      assertEquals("Key no-such-key not found", e.getMessage());
    }
    provider.flush();

    // get a new instance of the provider to ensure it was saved correctly
    provider = KeyProviderFactory.getProviders(conf).get(0);
    assertArrayEquals(new byte[]{2},
        provider.getCurrentKey("key4").getMaterial());
    assertArrayEquals(key3, provider.getCurrentKey("key3").getMaterial());
    assertEquals("key3@0", provider.getCurrentKey("key3").getVersionName());

    List<String> keys = provider.getKeys();
    assertTrue(keys.size() == 2, "Keys should have been returned.");
    assertTrue(keys.contains("key3"), "Returned Keys should have included key3.");
    assertTrue(keys.contains("key4"), "Returned Keys should have included key4.");

    List<KeyVersion> kvl = provider.getKeyVersions("key3");
    assertEquals(1, kvl.size(), "KeyVersions should have been returned for key3.");
    assertEquals("key3@0", kvl.get(0).getVersionName(),
        "KeyVersions should have included key3@0.");
    assertArrayEquals(key3, kvl.get(0).getMaterial());
  }

  @Test
  public void testUserProvider() throws Exception {
    Configuration conf = new Configuration();
    final String ourUrl = UserProvider.SCHEME_NAME + ":///";
    conf.set(KeyProviderFactory.KEY_PROVIDER_PATH, ourUrl);
    checkSpecificProvider(conf, ourUrl);
    // see if the credentials are actually in the UGI
    Credentials credentials =
        UserGroupInformation.getCurrentUser().getCredentials();
    assertArrayEquals(new byte[]{1},
        credentials.getSecretKey(new Text("key4@0")));
    assertArrayEquals(new byte[]{2},
        credentials.getSecretKey(new Text("key4@1")));
  }

  @Test
  public void testJksProvider() throws Exception {
    Configuration conf = new Configuration();
    final Path jksPath = new Path(testRootDir.toString(), "test.jks");
    final String ourUrl =
        JavaKeyStoreProvider.SCHEME_NAME + "://file" + jksPath.toUri();

    File file = new File(testRootDir, "test.jks");
    file.delete();
    conf.set(KeyProviderFactory.KEY_PROVIDER_PATH, ourUrl);
    checkSpecificProvider(conf, ourUrl);

    // START : Test flush error by failure injection
    conf.set(KeyProviderFactory.KEY_PROVIDER_PATH, ourUrl.replace(
        JavaKeyStoreProvider.SCHEME_NAME,
        FailureInjectingJavaKeyStoreProvider.SCHEME_NAME));
    // get a new instance of the provider to ensure it was saved correctly
    KeyProvider provider = KeyProviderFactory.getProviders(conf).get(0);
    // inject failure during keystore write
    FailureInjectingJavaKeyStoreProvider fProvider =
        (FailureInjectingJavaKeyStoreProvider) provider;
    fProvider.setWriteFail(true);
    provider.createKey("key5", new byte[]{1},
        KeyProvider.options(conf).setBitLength(8));
    assertNotNull(provider.getCurrentKey("key5"));
    try {
      provider.flush();
      fail("Should not succeed");
    } catch (Exception e) {
      // Ignore
    }
    // Should be reset to pre-flush state
    assertNull(provider.getCurrentKey("key5"));
    
    // Un-inject last failure and
    // inject failure during keystore backup
    fProvider.setWriteFail(false);
    fProvider.setBackupFail(true);
    provider.createKey("key6", new byte[]{1},
        KeyProvider.options(conf).setBitLength(8));
    assertNotNull(provider.getCurrentKey("key6"));
    try {
      provider.flush();
      fail("Should not succeed");
    } catch (Exception e) {
      // Ignore
    }
    // Should be reset to pre-flush state
    assertNull(provider.getCurrentKey("key6"));
    // END : Test flush error by failure injection

    conf.set(KeyProviderFactory.KEY_PROVIDER_PATH, ourUrl.replace(
        FailureInjectingJavaKeyStoreProvider.SCHEME_NAME,
        JavaKeyStoreProvider.SCHEME_NAME));

    Path path = ProviderUtils.unnestUri(new URI(ourUrl));
    FileSystem fs = path.getFileSystem(conf);
    FileStatus s = fs.getFileStatus(path);
    assertEquals("rw-------", s.getPermission().toString());
    assertTrue(file.isFile(), file + " should exist");

    // Corrupt file and Check if JKS can reload from _OLD file
    File oldFile = new File(file.getPath() + "_OLD");
    file.renameTo(oldFile);
    file.delete();
    file.createNewFile();
    assertTrue(oldFile.exists());
    provider = KeyProviderFactory.getProviders(conf).get(0);
    assertTrue(file.exists());
    assertTrue(!oldFile.exists(), oldFile + "should be deleted");
    verifyAfterReload(file, provider);
    assertTrue(!oldFile.exists());

    // _NEW and current file should not exist together
    File newFile = new File(file.getPath() + "_NEW");
    newFile.createNewFile();
    try {
      provider = KeyProviderFactory.getProviders(conf).get(0);
      fail("_NEW and current file should not exist together !!");
    } catch (Exception e) {
      // Ignore
    } finally {
      if (newFile.exists()) {
        newFile.delete();
      }
    }

    // Load from _NEW file
    file.renameTo(newFile);
    file.delete();
    try {
      provider = KeyProviderFactory.getProviders(conf).get(0);
      assertFalse(newFile.exists());
      assertFalse(oldFile.exists());
    } catch (Exception e) {
      fail("JKS should load from _NEW file !!");
      // Ignore
    }
    verifyAfterReload(file, provider);

    // _NEW exists but corrupt.. must load from _OLD
    newFile.createNewFile();
    file.renameTo(oldFile);
    file.delete();
    try {
      provider = KeyProviderFactory.getProviders(conf).get(0);
      assertFalse(newFile.exists());
      assertFalse(oldFile.exists());
    } catch (Exception e) {
      fail("JKS should load from _OLD file !!");
      // Ignore
    } finally {
      if (newFile.exists()) {
        newFile.delete();
      }
    }
    verifyAfterReload(file, provider);

    // check permission retention after explicit change
    fs.setPermission(path, new FsPermission("777"));
    checkPermissionRetention(conf, ourUrl, path);

    // Check that an uppercase keyname results in an error
    provider = KeyProviderFactory.getProviders(conf).get(0);
    try {
      provider.createKey("UPPERCASE", KeyProvider.options(conf));
      fail("Expected failure on creating key name with uppercase " +
          "characters");
    } catch (IllegalArgumentException e) {
      GenericTestUtils.assertExceptionContains("Uppercase key names", e);
    }
  }

  private void verifyAfterReload(File file, KeyProvider provider)
      throws IOException {
    List<String> existingKeys = provider.getKeys();
    assertTrue(existingKeys.contains("key4"));
    assertTrue(existingKeys.contains("key3"));
    assertTrue(file.exists());
  }

  public void checkPermissionRetention(Configuration conf, String ourUrl, Path path) throws Exception {
    KeyProvider provider = KeyProviderFactory.getProviders(conf).get(0);
    // let's add a new key and flush and check that permissions are still set to 777
    byte[] key = new byte[16];
    for(int i =0; i < key.length; ++i) {
      key[i] = (byte) i;
    }
    // create a new key
    try {
      provider.createKey("key5", key, KeyProvider.options(conf));
    } catch (Exception e) {
      e.printStackTrace();
      throw e;
    }
    provider.flush();
    // get a new instance of the provider to ensure it was saved correctly
    provider = KeyProviderFactory.getProviders(conf).get(0);
    assertArrayEquals(key, provider.getCurrentKey("key5").getMaterial());

    FileSystem fs = path.getFileSystem(conf);
    FileStatus s = fs.getFileStatus(path);
    assertEquals("rwxrwxrwx", s.getPermission().toString(),
        "Permissions should have been retained from the preexisting keystore.");
  }

  @Test
  public void testJksProviderPasswordViaConfig() throws Exception {
    Configuration conf = new Configuration();
    final Path jksPath = new Path(testRootDir.toString(), "test.jks");
    final String ourUrl =
        JavaKeyStoreProvider.SCHEME_NAME + "://file" + jksPath.toUri();
    File file = new File(testRootDir, "test.jks");
    file.delete();
    try {
      conf.set(KeyProviderFactory.KEY_PROVIDER_PATH, ourUrl);
      conf.set(JavaKeyStoreProvider.KEYSTORE_PASSWORD_FILE_KEY,
          "javakeystoreprovider.password");
      KeyProvider provider = KeyProviderFactory.getProviders(conf).get(0);
      provider.createKey("key3", new byte[16], KeyProvider.options(conf));
      provider.flush();
    } catch (Exception ex) {
      fail("could not create keystore with password file");
    }
    KeyProvider provider = KeyProviderFactory.getProviders(conf).get(0);
    assertNotNull(provider.getCurrentKey("key3"));

    try {
      conf.set(JavaKeyStoreProvider.KEYSTORE_PASSWORD_FILE_KEY, "bar");
      KeyProviderFactory.getProviders(conf).get(0);
      fail("using non existing password file, it should fail");
    } catch (IOException ex) {
      //NOP
    }
    try {
      conf.set(JavaKeyStoreProvider.KEYSTORE_PASSWORD_FILE_KEY, "core-site.xml");
      KeyProviderFactory.getProviders(conf).get(0);
      fail("using different password file, it should fail");
    } catch (IOException ex) {
      //NOP
    }
    try {
      conf.unset(JavaKeyStoreProvider.KEYSTORE_PASSWORD_FILE_KEY);
      KeyProviderFactory.getProviders(conf).get(0);
      fail("No password file property, env not set, it should fail");
    } catch (IOException ex) {
      //NOP
    }
  }

  @Test
  public void testGetProviderViaURI() throws Exception {
    Configuration conf = new Configuration(false);
    final Path jksPath = new Path(testRootDir.toString(), "test.jks");
    URI uri = new URI(JavaKeyStoreProvider.SCHEME_NAME + "://file" + jksPath.toUri());
    KeyProvider kp = KeyProviderFactory.get(uri, conf);
    assertNotNull(kp);
    assertEquals(JavaKeyStoreProvider.class, kp.getClass());
    uri = new URI("foo://bar");
    kp = KeyProviderFactory.get(uri, conf);
    assertNull(kp);

  }

  @Test
  public void testJksProviderWithKeytoolKeys() throws Exception {
    final Configuration conf = new Configuration();
    final String keystoreDirAbsolutePath =
        conf.getResource("hdfs7067.keystore").getPath();
    final String ourUrl = JavaKeyStoreProvider.SCHEME_NAME + "://file@/" +
        keystoreDirAbsolutePath;

    conf.set(KeyProviderFactory.KEY_PROVIDER_PATH, ourUrl);

    final KeyProvider provider = KeyProviderFactory.getProviders(conf).get(0);

    // Sanity check that we are using the right keystore
    @SuppressWarnings("unused")
    final KeyProvider.KeyVersion keyVersion =
            provider.getKeyVersion("testkey5@0");
    try {
      @SuppressWarnings("unused")
      final KeyProvider.KeyVersion keyVersionWrongKeyNameFormat =
          provider.getKeyVersion("testkey2");
      fail("should have thrown an exception");
    } catch (IOException e) {
      // No version in key path testkey2/
      GenericTestUtils.assertExceptionContains("No version in key path", e);
    }
    try {
      @SuppressWarnings("unused")
      final KeyProvider.KeyVersion keyVersionCurrentKeyNotWrongKeyNameFormat =
          provider.getCurrentKey("testkey5@0");
      fail("should have thrown an exception getting testkey5@0");
    } catch (IOException e) {
      // javax.crypto.spec.SecretKeySpec cannot be cast to
      // org.apache.hadoop.crypto.key.JavaKeyStoreProvider$KeyMetadata
      GenericTestUtils.assertExceptionContains("other non-Hadoop method", e);
    }
    try {
      @SuppressWarnings("unused")
      KeyProvider.KeyVersion keyVersionCurrentKeyNotReally =
          provider.getCurrentKey("testkey2");
      fail("should have thrown an exception getting testkey2");
    } catch (IOException e) {
      // javax.crypto.spec.SecretKeySpec cannot be cast to
      // org.apache.hadoop.crypto.key.JavaKeyStoreProvider$KeyMetadata
      GenericTestUtils.assertExceptionContains("other non-Hadoop method", e);
    }
  }
}
