diff --git a/src/main/java/com/github/dockerjava/core/CompressArchiveUtil.java b/src/main/java/com/github/dockerjava/core/CompressArchiveUtil.java index a9a972382..f07fa1b25 100644 --- a/src/main/java/com/github/dockerjava/core/CompressArchiveUtil.java +++ b/src/main/java/com/github/dockerjava/core/CompressArchiveUtil.java @@ -37,7 +37,7 @@ public static File archiveTARFiles(File base, Iterable files, String archi return tarFile; } - private static String relativize(File base, File absolute) { + public static String relativize(File base, File absolute) { String relative = base.toURI().relativize(absolute.toURI()).getPath(); return relative; } diff --git a/src/main/java/com/github/dockerjava/core/GoLangFileMatch.java b/src/main/java/com/github/dockerjava/core/GoLangFileMatch.java new file mode 100644 index 000000000..a84bce5bb --- /dev/null +++ b/src/main/java/com/github/dockerjava/core/GoLangFileMatch.java @@ -0,0 +1,257 @@ +/** + * Copyright (C) 2014 SignalFuse, Inc. + */ +package com.github.dockerjava.core; + +import java.io.File; +import java.util.List; + +import org.apache.commons.lang.StringUtils; + +/** + * Implementation of golang's file.Match + * + * Match returns true if name matches the shell file name pattern. The pattern syntax is: + * + *
+ *  pattern:
+ *          { term }
+ *   term:
+ *       '*'         matches any sequence of non-Separator characters
+ *      '?'         matches any single non-Separator character
+ *       '[' [ '^' ] { character-range } ']'
+ *                  character class (must be non-empty)
+ *       c           matches character c (c != '*', '?', '\\', '[')
+ *       '\\' c      matches character c
+ *
+ *   character-range:
+ *       c           matches character c (c != '\\', '-', ']')
+ *       '\\' c      matches character c
+ *       lo '-' hi   matches character c for lo <= c <= hi
+ *
+ *  Match requires pattern to match all of name, not just a substring.
+ *  The only possible returned error is ErrBadPattern, when pattern
+ *  is malformed.
+ *
+ * On Windows, escaping is disabled. Instead, '\\' is treated as
+ *  path separator.
+ * 
+ * + * @author tedo + * + */ +public class GoLangFileMatch { + + public static final boolean IS_WINDOWS = File.separatorChar == '\\'; + + public static boolean match(List patterns, File file) { + return match(patterns, file.getPath()); + } + + public static boolean match(String pattern, File file) { + return match(pattern, file.getPath()); + } + + public static boolean match(List patterns, String name) { + for (String pattern : patterns) { + if (match(pattern, name)) { + return true; + } + } + return false; + } + + public static boolean match(String pattern, String name) { + Pattern: while (!pattern.isEmpty()) { + ScanResult scanResult = scanChunk(pattern); + pattern = scanResult.pattern; + if (scanResult.star && StringUtils.isEmpty(scanResult.chunk)) { + // Trailing * matches rest of string unless it has a /. + return name.indexOf(File.separatorChar) < 0; + } + // Look for match at current position. + String matchResult = matchChunk(scanResult.chunk, name); + + // if we're the last chunk, make sure we've exhausted the name + // otherwise we'll give a false result even if we could still match + // using the star + if (matchResult != null && (matchResult.isEmpty() || !pattern.isEmpty())) { + name = matchResult; + continue; + } + if (scanResult.star) { + for (int i = 0; i < name.length() && name.charAt(i) != File.separatorChar; i++) { + matchResult = matchChunk(scanResult.chunk, name.substring(i + 1)); + if (matchResult != null) { + // if we're the last chunk, make sure we exhausted the name + if (pattern.isEmpty() && !matchResult.isEmpty()) { + continue; + } + name = matchResult; + continue Pattern; + } + } + } + return false; + } + return name.isEmpty(); + } + + static ScanResult scanChunk(String pattern) { + boolean star = false; + if (!pattern.isEmpty() && pattern.charAt(0) == '*') { + pattern = pattern.substring(1); + star = true; + } + boolean inRange = false; + int i; + Scan: for (i = 0; i < pattern.length(); i++) { + switch (pattern.charAt(i)) { + case '\\': { + if (!IS_WINDOWS) { + // error check handled in matchChunk: bad pattern. + if (i + 1 < pattern.length()) { + i++; + } + } + break; + } + case '[': + inRange = true; + break; + case ']': + inRange = false; + break; + case '*': + if (!inRange) { + break Scan; + } + } + } + return new ScanResult(star, pattern.substring(0, i), pattern.substring(i)); + } + + static String matchChunk(String chunk, String s) { + int chunkLength = chunk.length(); + int chunkOffset = 0; + int sLength = s.length(); + int sOffset = 0; + char r; + while (chunkOffset < chunkLength) { + if (sOffset == sLength) { + return null; + } + switch (chunk.charAt(chunkOffset)) { + case '[': + r = s.charAt(sOffset); + sOffset++; + chunkOffset++; + // We can't end right after '[', we're expecting at least + // a closing bracket and possibly a caret. + if (chunkOffset == chunkLength) { + throw new GoLangFileMatchException(); + } + // possibly negated + boolean negated = chunk.charAt(chunkOffset) == '^'; + if (negated) { + chunkOffset++; + } + // parse all ranges + boolean match = false; + int nrange = 0; + while (true) { + if (chunkOffset < chunkLength && chunk.charAt(chunkOffset) == ']' && nrange > 0) { + chunkOffset++; + break; + } + GetEscResult result = getEsc(chunk, chunkOffset, chunkLength); + char lo = result.lo; + char hi = lo; + chunkOffset = result.chunkOffset; + if (chunk.charAt(chunkOffset) == '-') { + result = getEsc(chunk, ++chunkOffset, chunkLength); + chunkOffset = result.chunkOffset; + hi = result.lo; + } + if (lo <= r && r <= hi) { + match = true; + } + nrange++; + } + if (match == negated) { + return null; + } + break; + + case '?': + if (s.charAt(sOffset) == File.separatorChar) { + return null; + } + sOffset++; + chunkOffset++; + break; + case '\\': + if (!IS_WINDOWS) { + chunkOffset++; + if (chunkOffset == chunkLength) { + throw new GoLangFileMatchException(); + } + } + // fallthrough + default: + if (chunk.charAt(chunkOffset) != s.charAt(sOffset)) { + return null; + } + sOffset++; + chunkOffset++; + } + } + return s.substring(sOffset); + } + + static GetEscResult getEsc(String chunk, int chunkOffset, int chunkLength) { + if (chunkOffset == chunkLength) { + throw new GoLangFileMatchException(); + } + char r = chunk.charAt(chunkOffset); + if (r == '-' || r == ']') { + throw new GoLangFileMatchException(); + } + if (r == '\\' && !IS_WINDOWS) { + chunkOffset++; + if (chunkOffset == chunkLength) { + throw new GoLangFileMatchException(); + } + + } + r = chunk.charAt(chunkOffset); + chunkOffset++; + if (chunkOffset == chunkLength) { + throw new GoLangFileMatchException(); + } + return new GetEscResult(r, chunkOffset); + } + + private static final class ScanResult { + public boolean star; + public String chunk; + public String pattern; + + public ScanResult(boolean star, String chunk, String pattern) { + this.star = star; + this.chunk = chunk; + this.pattern = pattern; + } + } + + private static final class GetEscResult { + public char lo; + public int chunkOffset; + + public GetEscResult(char lo, int chunkOffset) { + this.lo = lo; + this.chunkOffset = chunkOffset; + } + } + +} diff --git a/src/main/java/com/github/dockerjava/core/GoLangFileMatchException.java b/src/main/java/com/github/dockerjava/core/GoLangFileMatchException.java new file mode 100644 index 000000000..d1f353938 --- /dev/null +++ b/src/main/java/com/github/dockerjava/core/GoLangFileMatchException.java @@ -0,0 +1,10 @@ +/** + * Copyright (C) 2014 SignalFuse, Inc. + */ +package com.github.dockerjava.core; + +public class GoLangFileMatchException extends IllegalArgumentException { + + private static final long serialVersionUID = -1204971075600864898L; + +} diff --git a/src/main/java/com/github/dockerjava/core/GoLangMatchFileFilter.java b/src/main/java/com/github/dockerjava/core/GoLangMatchFileFilter.java new file mode 100644 index 000000000..cfd32a7ed --- /dev/null +++ b/src/main/java/com/github/dockerjava/core/GoLangMatchFileFilter.java @@ -0,0 +1,27 @@ +/** + * Copyright (C) 2014 SignalFuse, Inc. + */ +package com.github.dockerjava.core; + +import java.io.File; +import java.util.List; + +import org.apache.commons.io.filefilter.AbstractFileFilter; + +public class GoLangMatchFileFilter extends AbstractFileFilter { + + private final List patterns; + + + public GoLangMatchFileFilter(List patterns) { + super(); + this.patterns = patterns; + } + + @Override + public boolean accept(File file) { + return !GoLangFileMatch.match(patterns, file); + } + + +} diff --git a/src/main/java/com/github/dockerjava/core/command/BuildImageCmdImpl.java b/src/main/java/com/github/dockerjava/core/command/BuildImageCmdImpl.java index 4948ce45b..3884a3745 100644 --- a/src/main/java/com/github/dockerjava/core/command/BuildImageCmdImpl.java +++ b/src/main/java/com/github/dockerjava/core/command/BuildImageCmdImpl.java @@ -14,11 +14,15 @@ import java.util.regex.Pattern; import org.apache.commons.io.FileUtils; +import org.apache.commons.io.FilenameUtils; +import org.apache.commons.io.filefilter.TrueFileFilter; import com.github.dockerjava.api.DockerClientException; import com.github.dockerjava.api.command.BuildImageCmd; import com.github.dockerjava.core.CompressArchiveUtil; - +import com.github.dockerjava.core.GoLangFileMatch; +import com.github.dockerjava.core.GoLangFileMatchException; +import com.github.dockerjava.core.GoLangMatchFileFilter; import com.google.common.base.Preconditions; /** @@ -158,6 +162,30 @@ protected InputStream buildDockerFolderTar(File dockerFolder) { "Dockerfile %s is empty", dockerFile)); } + List ignores = new ArrayList(); + File dockerIgnoreFile = new File(dockerFolder, ".dockerignore"); + if (dockerIgnoreFile.exists()) { + int lineNumber = 0; + List dockerIgnoreFileContent = FileUtils.readLines(dockerIgnoreFile); + for (String pattern: dockerIgnoreFileContent) { + lineNumber++; + pattern = pattern.trim(); + if (pattern.isEmpty()) { + continue; // skip empty lines + } + pattern = FilenameUtils.normalize(pattern); + try { + // validate pattern and make sure we aren't excluding Dockerfile + if (GoLangFileMatch.match(pattern, "Dockerfile")) { + throw new DockerClientException( + String.format("Dockerfile is excluded by pattern '%s' on line %s in .dockerignore file", pattern, lineNumber)); + } + ignores.add(pattern); + } catch (GoLangFileMatchException e) { + throw new DockerClientException(String.format("Invalid pattern '%s' on line %s in .dockerignore file", pattern, lineNumber)); + } + } + } List filesToAdd = new ArrayList(); filesToAdd.add(dockerFile); @@ -215,10 +243,13 @@ protected InputStream buildDockerFolderTar(File dockerFolder) { "Source file %s doesn't exist", src)); } if (src.isDirectory()) { - filesToAdd.addAll(FileUtils.listFiles(src, null, - true)); - } else { + filesToAdd.addAll(FileUtils.listFiles(src, + new GoLangMatchFileFilter(ignores), TrueFileFilter.INSTANCE)); + } else if (!GoLangFileMatch.match(ignores, CompressArchiveUtil.relativize(dockerFolder, src))){ filesToAdd.add(src); + } else { + throw new DockerClientException(String.format( + "Source file %s is excluded by .dockerignore file", src)); } } } diff --git a/src/test/java/com/github/dockerjava/core/GoLangFileMatchTest.java b/src/test/java/com/github/dockerjava/core/GoLangFileMatchTest.java new file mode 100644 index 000000000..b556a7e5e --- /dev/null +++ b/src/test/java/com/github/dockerjava/core/GoLangFileMatchTest.java @@ -0,0 +1,122 @@ +/** + * Copyright (C) 2014 SignalFuse, Inc. + */ +package com.github.dockerjava.core; + +import java.io.File; +import java.io.IOException; + +import junit.framework.Assert; + +import org.apache.commons.io.FilenameUtils; +import org.testng.annotations.DataProvider; +import org.testng.annotations.Test; + +public class GoLangFileMatchTest { + + @Test(dataProvider = "getTestData") + public void testMatch(MatchTestCase testCase) throws IOException { + String pattern = testCase.pattern; + String s = testCase.s; + if (GoLangFileMatch.IS_WINDOWS) { + if (pattern.indexOf('\\') > 0) { + // no escape allowed on windows. + return; + } + pattern = FilenameUtils.normalize(pattern); + s = FilenameUtils.normalize(s); + } + try { + boolean matched = GoLangFileMatch.match(pattern, s); + if (testCase.expectException) { + Assert.fail("Expected GoFileMatchException"); + } + Assert.assertEquals(testCase.matches, matched); + } catch (GoLangFileMatchException e) { + if (!testCase.expectException) { + throw e; + } + } + } + + @DataProvider + public Object[][] getTestData() { + return new Object[][] { + new Object[] { new MatchTestCase("abc", "abc", true, false) }, + new Object[] { new MatchTestCase("*", "abc", true, false) }, + new Object[] { new MatchTestCase("*c", "abc", true, false) }, + new Object[] { new MatchTestCase("a*", "a", true, false) }, + new Object[] { new MatchTestCase("a*", "abc", true, false) }, + new Object[] { new MatchTestCase("a*", "ab/c", false, false) }, + new Object[] { new MatchTestCase("a*/b", "abc/b", true, false) }, + new Object[] { new MatchTestCase("a*/b", "a/c/b", false, false) }, + new Object[] { new MatchTestCase("a*b*c*d*e*/f", "axbxcxdxe/f", true, false) }, + new Object[] { new MatchTestCase("a*b*c*d*e*/f", "axbxcxdxexxx/f", true, false) }, + new Object[] { new MatchTestCase("a*b*c*d*e*/f", "axbxcxdxe/xxx/f", false, false) }, + new Object[] { new MatchTestCase("a*b*c*d*e*/f", "axbxcxdxexxx/fff", false, false) }, + new Object[] { new MatchTestCase("a*b?c*x", "abxbbxdbxebxczzx", true, false) }, + new Object[] { new MatchTestCase("a*b?c*x", "abxbbxdbxebxczzy", false, false) }, + new Object[] { new MatchTestCase("ab[c]", "abc", true, false) }, + new Object[] { new MatchTestCase("ab[b-d]", "abc", true, false) }, + new Object[] { new MatchTestCase("ab[e-g]", "abc", false, false) }, + new Object[] { new MatchTestCase("ab[^c]", "abc", false, false) }, + new Object[] { new MatchTestCase("ab[^b-d]", "abc", false, false) }, + new Object[] { new MatchTestCase("ab[^e-g]", "abc", true, false) }, + new Object[] { new MatchTestCase("a\\*b", "a*b", true, false) }, + new Object[] { new MatchTestCase("a\\*b", "ab", false, false) }, + new Object[] { new MatchTestCase("a?b", "a☺b", true, false) }, + new Object[] { new MatchTestCase("a[^a]b", "a☺b", true, false) }, + new Object[] { new MatchTestCase("a???b", "a☺b", false, false) }, + new Object[] { new MatchTestCase("a[^a][^a][^a]b", "a☺b", false, false) }, + new Object[] { new MatchTestCase("[a-ζ]*", "α", true, false) }, + new Object[] { new MatchTestCase("*[a-ζ]", "A", false, false) }, + new Object[] { new MatchTestCase("a?b", "a/b", false, false) }, + new Object[] { new MatchTestCase("a*b", "a/b", false, false) }, + new Object[] { new MatchTestCase("[\\]a]", "]", true, false) }, + new Object[] { new MatchTestCase("[\\-]", "-", true, false) }, + new Object[] { new MatchTestCase("[x\\-]", "x", true, false) }, + new Object[] { new MatchTestCase("[x\\-]", "-", true, false) }, + new Object[] { new MatchTestCase("[x\\-]", "z", false, false) }, + new Object[] { new MatchTestCase("[\\-x]", "x", true, false) }, + new Object[] { new MatchTestCase("[\\-x]", "-", true, false) }, + new Object[] { new MatchTestCase("[\\-x]", "a", false, false) }, + new Object[] { new MatchTestCase("[]a]", "]", false, true) }, + new Object[] { new MatchTestCase("[-]", "-", false, true) }, + new Object[] { new MatchTestCase("[x-]", "x", false, true) }, + new Object[] { new MatchTestCase("[x-]", "-", false, true) }, + new Object[] { new MatchTestCase("[x-]", "z", false, true) }, + new Object[] { new MatchTestCase("[-x]", "x", false, true) }, + new Object[] { new MatchTestCase("[-x]", "-", false, true) }, + new Object[] { new MatchTestCase("[-x]", "a", false, true) }, + new Object[] { new MatchTestCase("\\", "a", false, true) }, + new Object[] { new MatchTestCase("[a-b-c]", "a", false, true) }, + new Object[] { new MatchTestCase("[", "a", false, true) }, + new Object[] { new MatchTestCase("[^", "a", false, true) }, + new Object[] { new MatchTestCase("[^bc", "a", false, true) }, + new Object[] { new MatchTestCase("a[", "a", false, false) }, + new Object[] { new MatchTestCase("a[", "ab", false, true) }, + new Object[] { new MatchTestCase("*x", "xxx", true, false) } }; + } + + private final class MatchTestCase { + private final String pattern; + private final String s; + private final boolean matches; + private final boolean expectException; + + public MatchTestCase(String pattern, String s, boolean matches, boolean expectException) { + super(); + this.pattern = pattern; + this.s = s; + this.matches = matches; + this.expectException = expectException; + } + + @Override + public String toString() { + return "MatchTestCase [pattern=" + pattern + ", s=" + s + ", matches=" + matches + + ", expectException=" + expectException + "]"; + } + } + +} diff --git a/src/test/java/com/github/dockerjava/core/command/BuildImageCmdImplTest.java b/src/test/java/com/github/dockerjava/core/command/BuildImageCmdImplTest.java index 62603706d..7ae29e534 100644 --- a/src/test/java/com/github/dockerjava/core/command/BuildImageCmdImplTest.java +++ b/src/test/java/com/github/dockerjava/core/command/BuildImageCmdImplTest.java @@ -22,6 +22,7 @@ import org.testng.annotations.BeforeTest; import org.testng.annotations.Test; +import com.github.dockerjava.api.DockerClientException; import com.github.dockerjava.api.DockerException; import com.github.dockerjava.api.command.CreateContainerResponse; import com.github.dockerjava.api.command.InspectContainerResponse; @@ -132,6 +133,28 @@ private InputStream logContainer(String containerId) { return dockerClient.logContainerCmd(containerId).withStdErr().withStdOut().exec(); } + @Test(expectedExceptions={DockerClientException.class}) + public void testDockerfileIgnored() { + File baseDir = new File(Thread.currentThread().getContextClassLoader() + .getResource("testDockerfileIgnored").getFile()); + dockerClient.buildImageCmd(baseDir).withNoCache().exec(); + } + + @Test(expectedExceptions={DockerClientException.class}) + public void testInvalidDockerIgnorePattern() { + File baseDir = new File(Thread.currentThread().getContextClassLoader() + .getResource("testInvalidDockerignorePattern").getFile()); + dockerClient.buildImageCmd(baseDir).withNoCache().exec(); + } + + @Test + public void testDockerIgnore() throws DockerException, + IOException { + File baseDir = new File(Thread.currentThread().getContextClassLoader() + .getResource("testDockerignore").getFile()); + dockerfileBuild(baseDir, "/tmp/a/a /tmp/a/c /tmp/a/d"); + } + @Test public void testNetCatDockerfileBuilder() throws InterruptedException { File baseDir = new File(Thread.currentThread().getContextClassLoader() diff --git a/src/test/resources/testDockerfileIgnored/.dockerignore b/src/test/resources/testDockerfileIgnored/.dockerignore new file mode 100644 index 000000000..9e00faa1d --- /dev/null +++ b/src/test/resources/testDockerfileIgnored/.dockerignore @@ -0,0 +1,2 @@ +Dockerfile +*~ \ No newline at end of file diff --git a/src/test/resources/testDockerfileIgnored/Dockerfile b/src/test/resources/testDockerfileIgnored/Dockerfile new file mode 100644 index 000000000..8a4a67760 --- /dev/null +++ b/src/test/resources/testDockerfileIgnored/Dockerfile @@ -0,0 +1,9 @@ +FROM ubuntu:latest + +# Copy testrun.sh files into the container + +ADD ./testrun.sh /tmp/ + +RUN cp /tmp/testrun.sh /usr/local/bin/ && chmod +x /usr/local/bin/testrun.sh + +CMD ["testrun.sh"] diff --git a/src/test/resources/testDockerfileIgnored/testrun.sh b/src/test/resources/testDockerfileIgnored/testrun.sh new file mode 100755 index 000000000..80b468e71 --- /dev/null +++ b/src/test/resources/testDockerfileIgnored/testrun.sh @@ -0,0 +1,3 @@ +#!/bin/sh + +echo "Successfully executed testrun.sh" diff --git a/src/test/resources/testDockerignore/.dockerignore b/src/test/resources/testDockerignore/.dockerignore new file mode 100644 index 000000000..617807982 --- /dev/null +++ b/src/test/resources/testDockerignore/.dockerignore @@ -0,0 +1 @@ +b diff --git a/src/test/resources/testDockerignore/Dockerfile b/src/test/resources/testDockerignore/Dockerfile new file mode 100644 index 000000000..97b93b899 --- /dev/null +++ b/src/test/resources/testDockerignore/Dockerfile @@ -0,0 +1,10 @@ +FROM ubuntu:latest + +# Copy testrun.sh files into the container + +ADD ./testrun.sh /tmp/ +ADD ./a /tmp/a + +RUN cp /tmp/testrun.sh /usr/local/bin/ && chmod +x /usr/local/bin/testrun.sh + +CMD ["testrun.sh"] diff --git a/src/test/resources/testDockerignore/a/a b/src/test/resources/testDockerignore/a/a new file mode 100644 index 000000000..e69de29bb diff --git a/src/test/resources/testDockerignore/a/b b/src/test/resources/testDockerignore/a/b new file mode 100644 index 000000000..e69de29bb diff --git a/src/test/resources/testDockerignore/a/c b/src/test/resources/testDockerignore/a/c new file mode 100644 index 000000000..e69de29bb diff --git a/src/test/resources/testDockerignore/a/d b/src/test/resources/testDockerignore/a/d new file mode 100644 index 000000000..e69de29bb diff --git a/src/test/resources/testDockerignore/testrun.sh b/src/test/resources/testDockerignore/testrun.sh new file mode 100755 index 000000000..a6f7f3fee --- /dev/null +++ b/src/test/resources/testDockerignore/testrun.sh @@ -0,0 +1,3 @@ +#!/bin/sh + +echo /tmp/a/* diff --git a/src/test/resources/testInvalidDockerignorePattern/.dockerignore b/src/test/resources/testInvalidDockerignorePattern/.dockerignore new file mode 100644 index 000000000..89be209eb --- /dev/null +++ b/src/test/resources/testInvalidDockerignorePattern/.dockerignore @@ -0,0 +1,3 @@ +*~ +[a-b-c] + diff --git a/src/test/resources/testInvalidDockerignorePattern/Dockerfile b/src/test/resources/testInvalidDockerignorePattern/Dockerfile new file mode 100644 index 000000000..8a4a67760 --- /dev/null +++ b/src/test/resources/testInvalidDockerignorePattern/Dockerfile @@ -0,0 +1,9 @@ +FROM ubuntu:latest + +# Copy testrun.sh files into the container + +ADD ./testrun.sh /tmp/ + +RUN cp /tmp/testrun.sh /usr/local/bin/ && chmod +x /usr/local/bin/testrun.sh + +CMD ["testrun.sh"] diff --git a/src/test/resources/testInvalidDockerignorePattern/testrun.sh b/src/test/resources/testInvalidDockerignorePattern/testrun.sh new file mode 100755 index 000000000..80b468e71 --- /dev/null +++ b/src/test/resources/testInvalidDockerignorePattern/testrun.sh @@ -0,0 +1,3 @@ +#!/bin/sh + +echo "Successfully executed testrun.sh"