TestWinUtils.java

/**
 * 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.util;

import static org.apache.hadoop.test.PlatformAssumptions.assumeWindows;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;

import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.FileWriter;
import java.io.IOException;

import org.apache.commons.io.FileUtils;
import org.apache.hadoop.fs.FileUtil;
import org.apache.hadoop.test.GenericTestUtils;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Timeout;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * Test cases for helper Windows winutils.exe utility.
 */
public class TestWinUtils {

  private static final Logger LOG = LoggerFactory.getLogger(TestWinUtils.class);
  private static File TEST_DIR = GenericTestUtils.getTestDir(
      TestWinUtils.class.getSimpleName());

  String winutils;

  @BeforeEach
  public void setUp() throws IOException {
    // Not supported on non-Windows platforms
    assumeWindows();
    TEST_DIR.mkdirs();
    assertTrue(TEST_DIR.isDirectory(),
        "Failed to create Test directory " + TEST_DIR);
    winutils = Shell.getWinUtilsPath();
  }

  @AfterEach
  public void tearDown() throws IOException {
    FileUtil.fullyDelete(TEST_DIR);
  }

  private void requireWinutils() throws IOException {
    Shell.getWinUtilsPath();
  }

  // Helper routine that writes the given content to the file.
  private void writeFile(File file, String content) throws IOException {
    byte[] data = content.getBytes();
    try (FileOutputStream os = new FileOutputStream(file)) {
      os.write(data);
      os.close();
    }
  }

  // Helper routine that reads the first 100 bytes from the file.
  private String readFile(File file) throws IOException {
    byte[] b;
    try (FileInputStream fos = new FileInputStream(file)) {
      b = new byte[100];
      int count = fos.read(b);
      assertEquals(100, count);
    }
    return new String(b);
  }

  @Test
  @Timeout(value = 30)
  public void testLs() throws IOException {
    requireWinutils();
    final String content = "6bytes";
    final int contentSize = content.length();
    File testFile = new File(TEST_DIR, "file1");
    writeFile(testFile, content);

    // Verify permissions and file name return tokens
    String testPath = testFile.getCanonicalPath();
    String output = Shell.execCommand(
        winutils, "ls", testPath);
    String[] outputArgs = output.split("[ \r\n]");
    assertEquals("-rwx------", outputArgs[0]);
    assertEquals(outputArgs[outputArgs.length - 1], testPath);

    // Verify most tokens when using a formatted output (other tokens
    // will be verified with chmod/chown)
    output = Shell.execCommand(
        winutils, "ls", "-F", testPath);
    outputArgs = output.split("[|\r\n]");
    assertEquals(9, outputArgs.length);
    assertEquals("-rwx------", outputArgs[0]);
    assertEquals(contentSize, Long.parseLong(outputArgs[4]));
    assertEquals(outputArgs[8], testPath);

    testFile.delete();
    assertFalse(testFile.exists());
  }

  @Test
  @Timeout(value = 30)
  public void testGroups() throws IOException {
    requireWinutils();
    String currentUser = System.getProperty("user.name");

    // Verify that groups command returns information about the current user
    // groups when invoked with no args
    String outputNoArgs = Shell.execCommand(
        winutils, "groups").trim();
    String output = Shell.execCommand(
        winutils, "groups", currentUser).trim();
    assertEquals(output, outputNoArgs);

    // Verify that groups command with the -F flag returns the same information
    String outputFormat = Shell.execCommand(
        winutils, "groups", "-F", currentUser).trim();
    outputFormat = outputFormat.replace("|", " ");
    assertEquals(output, outputFormat);
  }

  private void chmod(String mask, File file) throws IOException {
    Shell.execCommand(
        winutils, "chmod", mask, file.getCanonicalPath());
  }

  private void chmodR(String mask, File file) throws IOException {
    Shell.execCommand(
        winutils, "chmod", "-R", mask, file.getCanonicalPath());
  }

  private String ls(File file) throws IOException {
    return Shell.execCommand(
        winutils, "ls", file.getCanonicalPath());
  }

  private String lsF(File file) throws IOException {
    return Shell.execCommand(
        winutils, "ls", "-F", file.getCanonicalPath());
  }

  private void assertPermissions(File file, String expected)
      throws IOException {
    String output = ls(file).split("[ \r\n]")[0];
    assertEquals(expected, output);
  }

  private void testChmodInternal(String mode, String expectedPerm)
      throws IOException {
    requireWinutils();
    File a = new File(TEST_DIR, "file1");
    assertTrue(a.createNewFile());

    // Reset permissions on the file to default
    chmod("700", a);

    // Apply the mode mask
    chmod(mode, a);

    // Compare the output
    assertPermissions(a, expectedPerm);

    a.delete();
    assertFalse(a.exists());
  }

  private void testNewFileChmodInternal(String expectedPerm) throws IOException {
    requireWinutils();
    // Create a new directory
    File dir = new File(TEST_DIR, "dir1");

    assertTrue(dir.mkdir());

    // Set permission use chmod
    chmod("755", dir);

    // Create a child file in the directory
    File child = new File(dir, "file1");
    assertTrue(child.createNewFile());

    // Verify the child file has correct permissions
    assertPermissions(child, expectedPerm);

    child.delete();
    dir.delete();
    assertFalse(dir.exists());
  }

  private void testChmodInternalR(String mode, String expectedPerm,
      String expectedPermx) throws IOException {
    requireWinutils();
    // Setup test folder hierarchy
    File a = new File(TEST_DIR, "a");
    assertTrue(a.mkdir());
    chmod("700", a);
    File aa = new File(a, "a");
    assertTrue(aa.createNewFile());
    chmod("600", aa);
    File ab = new File(a, "b");
    assertTrue(ab.mkdir());
    chmod("700", ab);
    File aba = new File(ab, "a");
    assertTrue(aba.mkdir());
    chmod("700", aba);
    File abb = new File(ab, "b");
    assertTrue(abb.createNewFile());
    chmod("600", abb);
    File abx = new File(ab, "x");
    assertTrue(abx.createNewFile());
    chmod("u+x", abx);

    // Run chmod recursive
    chmodR(mode, a);

    // Verify outcome
    assertPermissions(a, "d" + expectedPermx);
    assertPermissions(aa, "-" + expectedPerm);
    assertPermissions(ab, "d" + expectedPermx);
    assertPermissions(aba, "d" + expectedPermx);
    assertPermissions(abb, "-" + expectedPerm);
    assertPermissions(abx, "-" + expectedPermx);

    assertTrue(FileUtil.fullyDelete(a));
  }

  @Test
  @Timeout(value = 30)
  public void testBasicChmod() throws IOException {
    requireWinutils();
    // - Create a file.
    // - Change mode to 377 so owner does not have read permission.
    // - Verify the owner truly does not have the permissions to read.
    File a = new File(TEST_DIR, "a");
    a.createNewFile();
    chmod("377", a);

    try {
      readFile(a);
      assertFalse(true, "readFile should have failed!");
    } catch (IOException ex) {
      LOG.info("Expected: Failed read from a file with permissions 377");
    }
    // restore permissions
    chmod("700", a);

    // - Create a file.
    // - Change mode to 577 so owner does not have write permission.
    // - Verify the owner truly does not have the permissions to write.
    chmod("577", a);
 
    try {
      writeFile(a, "test");
      fail("writeFile should have failed!");
    } catch (IOException ex) {
      LOG.info("Expected: Failed write to a file with permissions 577");
    }
    // restore permissions
    chmod("700", a);
    assertTrue(a.delete());

    // - Copy WINUTILS to a new executable file, a.exe.
    // - Change mode to 677 so owner does not have execute permission.
    // - Verify the owner truly does not have the permissions to execute the file.

    File winutilsFile = Shell.getWinUtilsFile();
    File aExe = new File(TEST_DIR, "a.exe");
    FileUtils.copyFile(winutilsFile, aExe);
    chmod("677", aExe);

    try {
      Shell.execCommand(aExe.getCanonicalPath(), "ls");
      fail("executing " + aExe + " should have failed!");
    } catch (IOException ex) {
      LOG.info("Expected: Failed to execute a file with permissions 677");
    }
    assertTrue(aExe.delete());
  }

  /** Validate behavior of chmod commands on directories on Windows. */
  @Test
  @Timeout(value = 30)
  public void testBasicChmodOnDir() throws IOException {
    requireWinutils();
    // Validate that listing a directory with no read permission fails
    File a = new File(TEST_DIR, "a");
    File b = new File(a, "b");
    a.mkdirs();
    assertTrue(b.createNewFile());

    // Remove read permissions on directory a
    chmod("300", a);
    String[] files = a.list();
    assertNull(files, "Listing a directory without read permission should fail");

    // restore permissions
    chmod("700", a);
    // validate that the directory can be listed now
    files = a.list();
    assertEquals("b", files[0]);

    // Remove write permissions on the directory and validate the
    // behavior for adding, deleting and renaming files
    chmod("500", a);
    File c = new File(a, "c");
 
    try {
      // Adding a new file will fail as expected because the
      // FILE_WRITE_DATA/FILE_ADD_FILE privilege is denied on
      // the dir.
      c.createNewFile();
      fail("writeFile should have failed!");
    } catch (IOException ex) {
      LOG.info("Expected: Failed to create a file when directory "
          + "permissions are 577");
    }

    // Deleting a file will succeed even if write permissions are not present
    // on the parent dir. Check the following link for additional details:
    // http://support.microsoft.com/kb/238018
    assertTrue(b.delete(),
        "Special behavior: deleting a file will succeed on Windows "
        + "even if a user does not have write permissions on the parent dir");

    assertFalse(b.renameTo(new File(a, "d")),
        "Renaming a file should fail on the dir where a user does "
        + "not have write permissions");

    // restore permissions
    chmod("700", a);

    // Make sure adding new files and rename succeeds now
    assertTrue(c.createNewFile());
    File d = new File(a, "d");
    assertTrue(c.renameTo(d));
    // at this point in the test, d is the only remaining file in directory a

    // Removing execute permissions does not have the same behavior on
    // Windows as on Linux. Adding, renaming, deleting and listing files
    // will still succeed. Windows default behavior is to bypass directory
    // traverse checking (BYPASS_TRAVERSE_CHECKING privilege) for all users.
    // See the following link for additional details:
    // http://msdn.microsoft.com/en-us/library/windows/desktop/aa364399(v=vs.85).aspx
    chmod("600", a);

    // validate directory listing
    files = a.list();
    assertEquals("d", files[0]);
    // validate delete
    assertTrue(d.delete());
    // validate add
    File e = new File(a, "e");
    assertTrue(e.createNewFile());
    // validate rename
    assertTrue(e.renameTo(new File(a, "f")));

    // restore permissions
    chmod("700", a);
  }

  @Test
  @Timeout(value = 30)
  public void testChmod() throws IOException {
    requireWinutils();
    testChmodInternal("7", "-------rwx");
    testChmodInternal("70", "----rwx---");
    testChmodInternal("u-x,g+r,o=g", "-rw-r--r--");
    testChmodInternal("u-x,g+rw", "-rw-rw----");
    testChmodInternal("u-x,g+rwx-x,o=u", "-rw-rw-rw-");
    testChmodInternal("+", "-rwx------");

    // Recursive chmod tests
    testChmodInternalR("755", "rwxr-xr-x", "rwxr-xr-x");
    testChmodInternalR("u-x,g+r,o=g", "rw-r--r--", "rw-r--r--");
    testChmodInternalR("u-x,g+rw", "rw-rw----", "rw-rw----");
    testChmodInternalR("u-x,g+rwx-x,o=u", "rw-rw-rw-", "rw-rw-rw-");
    testChmodInternalR("a+rX", "rw-r--r--", "rwxr-xr-x");

    // Test a new file created in a chmod'ed directory has expected permission
    testNewFileChmodInternal("-rwxr-xr-x");
  }

  private void chown(String userGroup, File file) throws IOException {
    Shell.execCommand(
        winutils, "chown", userGroup, file.getCanonicalPath());
  }

  private void assertOwners(File file, String expectedUser,
      String expectedGroup) throws IOException {
    String [] args = lsF(file).trim().split("[\\|]");
    assertEquals(StringUtils.toLowerCase(expectedUser),
        StringUtils.toLowerCase(args[2]));
    assertEquals(StringUtils.toLowerCase(expectedGroup),
        StringUtils.toLowerCase(args[3]));
  }

  @Test
  @Timeout(value = 30)
  public void testChown() throws IOException {
    requireWinutils();
    File a = new File(TEST_DIR, "a");
    assertTrue(a.createNewFile());
    String username = System.getProperty("user.name");
    // username including the domain aka DOMAIN\\user
    String qualifiedUsername = Shell.execCommand("whoami").trim();
    String admins = "Administrators";
    String qualifiedAdmins = "BUILTIN\\Administrators";

    chown(username + ":" + admins, a);
    assertOwners(a, qualifiedUsername, qualifiedAdmins);
 
    chown(username, a);
    chown(":" + admins, a);
    assertOwners(a, qualifiedUsername, qualifiedAdmins);

    chown(":" + admins, a);
    chown(username + ":", a);
    assertOwners(a, qualifiedUsername, qualifiedAdmins);

    assertTrue(a.delete());
    assertFalse(a.exists());
  }

  @Test
  @Timeout(value = 30)
  public void testSymlinkRejectsForwardSlashesInLink() throws IOException {
    requireWinutils();
    File newFile = new File(TEST_DIR, "file");
    assertTrue(newFile.createNewFile());
    String target = newFile.getPath();
    String link = new File(TEST_DIR, "link").getPath().replaceAll("\\\\", "/");
    try {
      Shell.execCommand(winutils, "symlink", link, target);
      fail(String.format("did not receive expected failure creating symlink "
        + "with forward slashes in link: link = %s, target = %s", link, target));
    } catch (IOException e) {
      LOG.info(
        "Expected: Failed to create symlink with forward slashes in target");
    }
  }

  @Test
  @Timeout(value = 30)
  public void testSymlinkRejectsForwardSlashesInTarget() throws IOException {
    requireWinutils();
    File newFile = new File(TEST_DIR, "file");
    assertTrue(newFile.createNewFile());
    String target = newFile.getPath().replaceAll("\\\\", "/");
    String link = new File(TEST_DIR, "link").getPath();
    try {
      Shell.execCommand(winutils, "symlink", link, target);
      fail(String.format("did not receive expected failure creating symlink "
        + "with forward slashes in target: link = %s, target = %s", link, target));
    } catch (IOException e) {
      LOG.info(
        "Expected: Failed to create symlink with forward slashes in target");
    }
  }

  @Test
  @Timeout(value = 30)
  public void testReadLink() throws IOException {
    requireWinutils();
    // Create TEST_DIR\dir1\file1.txt
    //
    File dir1 = new File(TEST_DIR, "dir1");
    assertTrue(dir1.mkdirs());

    File file1 = new File(dir1, "file1.txt");
    assertTrue(file1.createNewFile());

    File dirLink = new File(TEST_DIR, "dlink");
    File fileLink = new File(TEST_DIR, "flink");

    // Next create a directory symlink to dir1 and a file
    // symlink to file1.txt.
    //
    Shell.execCommand(
        winutils, "symlink", dirLink.toString(), dir1.toString());
    Shell.execCommand(
        winutils, "symlink", fileLink.toString(), file1.toString());

    // Read back the two links and ensure we get what we expected.
    //
    String readLinkOutput = Shell.execCommand(winutils,
        "readlink",
        dirLink.toString());
    assertThat(readLinkOutput).isEqualTo(dir1.toString());

    readLinkOutput = Shell.execCommand(winutils,
        "readlink",
        fileLink.toString());
    assertThat(readLinkOutput).isEqualTo(file1.toString());

    // Try a few invalid inputs and verify we get an ExitCodeException for each.
    //
    try {
      // No link name specified.
      //
      Shell.execCommand(winutils, "readlink", "");
      fail("Failed to get Shell.ExitCodeException when reading bad symlink");
    } catch (Shell.ExitCodeException ece) {
      assertThat(ece.getExitCode()).isEqualTo(1);
    }

    try {
      // Bad link name.
      //
      Shell.execCommand(winutils, "readlink", "ThereIsNoSuchLink");
      fail("Failed to get Shell.ExitCodeException when reading bad symlink");
    } catch (Shell.ExitCodeException ece) {
      assertThat(ece.getExitCode()).isEqualTo(1);
    }

    try {
      // Non-symlink directory target.
      //
      Shell.execCommand(winutils, "readlink", dir1.toString());
      fail("Failed to get Shell.ExitCodeException when reading bad symlink");
    } catch (Shell.ExitCodeException ece) {
      assertThat(ece.getExitCode()).isEqualTo(1);
    }

    try {
      // Non-symlink file target.
      //
      Shell.execCommand(winutils, "readlink", file1.toString());
      fail("Failed to get Shell.ExitCodeException when reading bad symlink");
    } catch (Shell.ExitCodeException ece) {
      assertThat(ece.getExitCode()).isEqualTo(1);
    }

    try {
      // Too many parameters.
      //
      Shell.execCommand(winutils, "readlink", "a", "b");
      fail("Failed to get Shell.ExitCodeException with bad parameters");
    } catch (Shell.ExitCodeException ece) {
      assertThat(ece.getExitCode()).isEqualTo(1);
    }
  }
  
  @Test
  @Timeout(value = 10)
  public void testTaskCreate() throws IOException {
    requireWinutils();
    File batch = new File(TEST_DIR, "testTaskCreate.cmd");
    File proof = new File(TEST_DIR, "testTaskCreate.out");
    FileWriter fw = new FileWriter(batch);
    String testNumber = String.format("%f", Math.random());
    fw.write(String.format("echo %s > \"%s\"", testNumber, proof.getAbsolutePath()));
    fw.close();
    
    assertFalse(proof.exists());
    
    Shell.execCommand(winutils, "task", "create", "testTaskCreate" + testNumber,
        batch.getAbsolutePath());
    
    assertTrue(proof.exists());
    
    String outNumber = FileUtils.readFileToString(proof);

    assertThat(outNumber).contains(testNumber);
  }

  @Test
  @Timeout(value = 30)
  public void testTaskCreateWithLimits() throws IOException {
    requireWinutils();
    // Generate a unique job id
    String jobId = String.format("%f", Math.random());

    // Run a task without any options
    String out = Shell.execCommand(winutils, "task", "create",
        "job" + jobId, "cmd /c echo job" + jobId);
    assertTrue(out.trim().equals("job" + jobId));

    // Run a task without any limits
    jobId = String.format("%f", Math.random());
    out = Shell.execCommand(winutils, "task", "create", "-c", "-1", "-m",
        "-1", "job" + jobId, "cmd /c echo job" + jobId);
    assertTrue(out.trim().equals("job" + jobId));

    // Run a task with limits (128MB should be enough for a cmd)
    jobId = String.format("%f", Math.random());
    out = Shell.execCommand(winutils, "task", "create", "-c", "10000", "-m",
        "128", "job" + jobId, "cmd /c echo job" + jobId);
    assertTrue(out.trim().equals("job" + jobId));

    // Run a task without enough memory
    try {
      jobId = String.format("%f", Math.random());
      out = Shell.execCommand(winutils, "task", "create", "-m", "128", "job"
          + jobId, "java -Xmx256m -version");
      fail("Failed to get Shell.ExitCodeException with insufficient memory");
    } catch (Shell.ExitCodeException ece) {
      assertThat(ece.getExitCode()).isEqualTo(1);
    }

    // Run tasks with wrong parameters
    //
    try {
      jobId = String.format("%f", Math.random());
      Shell.execCommand(winutils, "task", "create", "-c", "-1", "-m",
          "-1", "foo", "job" + jobId, "cmd /c echo job" + jobId);
      fail("Failed to get Shell.ExitCodeException with bad parameters");
    } catch (Shell.ExitCodeException ece) {
      assertThat(ece.getExitCode()).isEqualTo(1639);
    }

    try {
      jobId = String.format("%f", Math.random());
      Shell.execCommand(winutils, "task", "create", "-c", "-m", "-1",
          "job" + jobId, "cmd /c echo job" + jobId);
      fail("Failed to get Shell.ExitCodeException with bad parameters");
    } catch (Shell.ExitCodeException ece) {
      assertThat(ece.getExitCode()).isEqualTo(1639);
    }

    try {
      jobId = String.format("%f", Math.random());
      Shell.execCommand(winutils, "task", "create", "-c", "foo",
          "job" + jobId, "cmd /c echo job" + jobId);
      fail("Failed to get Shell.ExitCodeException with bad parameters");
    } catch (Shell.ExitCodeException ece) {
      assertThat(ece.getExitCode()).isEqualTo(1639);
    }
  }
}