Skip to content

Commit

Permalink
Pattern matching for the command line tool
Browse files Browse the repository at this point in the history
  • Loading branch information
ebourg committed May 17, 2024
1 parent 46eb00e commit aaaa43f
Show file tree
Hide file tree
Showing 7 changed files with 363 additions and 6 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ See https://ebourg.github.io/jsign for more information.
* Signing of NuGet packages has been implemented (contributed by Sebastian Stamm)
* The intermediate certificates are downloaded if missing from the keystore or the certificate chain file
* File list files prefixed with `@` are now supported with the command line tool to sign multiple files
* Wildcard patterns are now accepted by the command line tool to scan directories for files to sign
* Jsign now checks if the certificate subject matches the app manifest publisher before signing APPX/MSIX packages
* The `extract` command has been added to extract the signature from a signed file, in DER or PEM format
* The `remove` command has been added to remove the signature from a signed file
Expand Down
7 changes: 5 additions & 2 deletions docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -482,7 +482,7 @@ <h3 id="cli">Command Line Tool</h3>
<p>The parameters expected are the same as those used by the Ant task:</p>

<pre>
usage: jsign [OPTIONS] [FILE] [@FILELIST]...
usage: jsign [OPTIONS] [FILE] [PATTERN] [@FILELIST]...
Sign and timestamp Windows executable files, Microsoft Installers (MSI), Cabinet
files (CAB), Catalog files (CAT), Windows packages (APPX/MSIX), Microsoft Dynamics
365 extension packages, NuGet packages and scripts (PowerShell, VBScript, JScript, WSF).
Expand Down Expand Up @@ -534,7 +534,10 @@ <h3 id="cli">Command Line Tool</h3>
-h,--help Print the help
</pre>

<p>After the options Jsign accepts one or more files to sign as arguments. If a filename starts with @ it is considered
<p>After the options Jsign accepts one or more files to sign as arguments. The arguments may contain <code>'*'</code>
or <code>'**'</code> wildcards to match multiple files and scan through directories recursively. For example using
<code>build/*.exe</code> will sign the executables in the build directory, and <code>installdir/**/*.dll</code> will
scan the installdir directory recursively and sign all the DLLs found. If an argument starts with @ it is considered
as a text file containing a list of files to sign, one per line.</p>

<h3 id="examples">Examples</h3>
Expand Down
167 changes: 167 additions & 0 deletions jsign-cli/src/main/java/net/jsign/DirectoryScanner.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
/**
* Copyright 2024 Emmanuel Bourg
*
* 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 net.jsign;

import java.io.File;
import java.io.IOException;
import java.nio.file.FileVisitResult;
import java.nio.file.FileVisitor;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.ArrayList;
import java.util.List;
import java.util.StringTokenizer;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
* Scans a directory recursively and returns the files matching a pattern.
*
* @since 6.1
*/
class DirectoryScanner {

/**
* Scans the current directory for files matching the specified pattern.
*
* @param glob the glob pattern ({@code foo/**}{@code /*bar/*.exe})
*/
public List<Path> scan(String glob) throws IOException {
// normalize the pattern
glob = glob.replace('\\', '/').replace("/+", "/");

// adjust the base directory
String basedir = findBaseDirectory(glob);

// strip the base directory from the pattern
glob = glob.substring(basedir.length());
Pattern pattern = Pattern.compile(globToRegExp(glob));

int maxDepth = maxPatternDepth(glob);

// let's scan the files
List<Path> matches = new ArrayList<>();
Files.walkFileTree(new File(basedir).toPath(), new FileVisitor<Path>() {
private int depth = -1;

@Override
public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) {
String name = dir.getFileName().toString();
if (depth + 1 > maxDepth || ".svn".equals(name) || ".git".equals(name)) {
return FileVisitResult.SKIP_SUBTREE;
} else {
depth++;
return FileVisitResult.CONTINUE;
}
}

@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
String filename = file.toString();
filename = filename.replaceAll("\\\\", "/");
if (filename.startsWith("./")) {
filename = filename.substring(2);
}
if (filename.startsWith(basedir)) {
filename = filename.substring(basedir.length());
}
if (pattern.matcher(filename).matches()) {
matches.add(file);
}

return FileVisitResult.CONTINUE;
}

@Override
public FileVisitResult visitFileFailed(Path file, IOException exc) {
return FileVisitResult.CONTINUE;
}

@Override
public FileVisitResult postVisitDirectory(Path dir, IOException exc) {
depth--;
return FileVisitResult.CONTINUE;
}
});

return matches;
}

/**
* Converts a glob pattern into a regular expression.
*
* @param glob the glob pattern to convert ({@code foo/**}{@code /bar/*.exe})
*/
String globToRegExp(String glob) {
String delimiters = "/\\";
StringTokenizer tokenizer = new StringTokenizer(glob, delimiters, true);

boolean ignoreNextSeparator = false;
StringBuilder pattern = new StringBuilder();
while (tokenizer.hasMoreTokens()) {
String token = tokenizer.nextToken();
if (token.length() == 1 && delimiters.contains(token)) {
if (!ignoreNextSeparator) {
pattern.append("/");
}
} else if ("**".equals(token)){
pattern.append("(?:|.*/)");
ignoreNextSeparator = true;
} else if (token.contains("*")) {
ignoreNextSeparator = false;
pattern.append("\\Q" + token.replaceAll("\\*", "\\\\E[^/]*\\\\Q") + "\\E");
} else {
ignoreNextSeparator = false;
pattern.append("\\Q").append(token).append("\\E");
}
}

return pattern.toString().replaceAll("/+", "/");
}

/**
* Finds the base directory of the specified pattern.
*/
String findBaseDirectory(String pattern) {
Pattern regexp = Pattern.compile("([^*]*/).*");
Matcher matcher = regexp.matcher(pattern);
if (matcher.matches()) {
return matcher.group(1);
} else {
return "";
}
}

/**
* Returns the maximum depth of the pattern (stripped from its base directory).
*/
int maxPatternDepth(String pattern) {
if (pattern.contains("**")) {
return 50;
}

int depth = 0;
for (int i = 0; i < pattern.length(); i++) {
if (pattern.charAt(i) == '/') {
depth++;
}
}

return depth;
}
}
9 changes: 8 additions & 1 deletion jsign-cli/src/main/java/net/jsign/JsignCLI.java
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import java.io.FileInputStream;
import java.io.IOException;
import java.io.PrintWriter;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedHashMap;
Expand Down Expand Up @@ -189,6 +190,12 @@ private List<String> expand(String filename) {
} catch (IOException e) {
throw new IllegalArgumentException("Failed to read the file list: " + filename.substring(1), e);
}
} else if (filename.contains("*")) {
try {
return new DirectoryScanner().scan(filename).stream().map(Path::toString).collect(Collectors.toList());
} catch (IOException e) {
throw new IllegalArgumentException("Failed to scan the directory: " + filename, e);
}
} else {
return Collections.singletonList(filename);
}
Expand Down Expand Up @@ -237,7 +244,7 @@ private void printHelp() {
formatter.setDescPadding(1);

PrintWriter out = new PrintWriter(System.out);
formatter.printUsage(out, formatter.getWidth(), getProgramName() + " [COMMAND] [OPTIONS] [FILE] [@FILELIST]...");
formatter.printUsage(out, formatter.getWidth(), getProgramName() + " [COMMAND] [OPTIONS] [FILE] [PATTERN] [@FILELIST]...");
out.println();
formatter.printWrapped(out, formatter.getWidth(), header);

Expand Down
156 changes: 156 additions & 0 deletions jsign-cli/src/test/java/net/jsign/DirectoryScannerTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
/**
* Copyright 2024 Emmanuel Bourg
*
* 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 net.jsign;

import java.io.File;
import java.nio.file.Path;
import java.util.List;

import org.junit.Test;

import static org.junit.Assert.*;

public class DirectoryScannerTest {

@Test
public void testGlobToRegExp() {
DirectoryScanner scanner = new DirectoryScanner();
assertEquals("RegExp for pattern *.exe", "\\Q\\E[^/]*\\Q.exe\\E", scanner.globToRegExp("*.exe"));
assertEquals("RegExp for pattern build/*.exe", "\\Qbuild\\E/\\Q\\E[^/]*\\Q.exe\\E", scanner.globToRegExp("build/*.exe"));
assertEquals("RegExp for pattern build/*.exe", "\\Qbuild\\E/\\Q\\E[^/]*\\Qapp\\E[^/]*\\Q.exe\\E", scanner.globToRegExp("build/*app*.exe"));
assertEquals("RegExp for pattern build//*.exe", "\\Qbuild\\E/\\Q\\E[^/]*\\Q.exe\\E", scanner.globToRegExp("build//*.exe"));
assertEquals("RegExp for pattern build\\*.exe", "\\Qbuild\\E/\\Q\\E[^/]*\\Q.exe\\E", scanner.globToRegExp("build\\*.exe"));
assertEquals("RegExp for pattern build/**/package.msix", "\\Qbuild\\E/(?:|.*/)\\Qpackage.msix\\E", scanner.globToRegExp("build/**/package.msix"));
assertEquals("RegExp for pattern build/**/artifacts/*.dll", "\\Qbuild\\E/(?:|.*/)\\Q\\E[^/]*\\Q.dll\\E", scanner.globToRegExp("build/**/*.dll"));
}

@Test
public void testFindBaseDirectory() {
DirectoryScanner scanner = new DirectoryScanner();
assertEquals("Base directory for pattern ''", "", scanner.findBaseDirectory(""));
assertEquals("Base directory for pattern *.exe", "", scanner.findBaseDirectory("*.exe"));
assertEquals("Base directory for pattern **/*.exe", "", scanner.findBaseDirectory("**/*.exe"));
assertEquals("Base directory for pattern /build/", "/build/", scanner.findBaseDirectory("/build/"));
assertEquals("Base directory for pattern build/*.exe", "build/", scanner.findBaseDirectory("build/*.exe"));
assertEquals("Base directory for pattern build/foo/**/bar/*.exe", "build/foo/", scanner.findBaseDirectory("build/foo/**/bar/*.exe"));
assertEquals("Base directory for pattern ../../foo/bar*/*.dll", "../../foo/", scanner.findBaseDirectory("../../foo/bar*/*.dll"));
assertEquals("Base directory for pattern ../../*foo*/bar*/*.dll", "../../", scanner.findBaseDirectory("../../*foo*/bar*/*.dll"));
assertEquals("Base directory for pattern c:/dev/jsign/*.xml", "c:/dev/jsign/", scanner.findBaseDirectory("c:/dev/jsign/*.xml"));
}

@Test
public void testMaxPatternDepth() {
DirectoryScanner scanner = new DirectoryScanner();
assertEquals("Max depth for pattern ''", 0, scanner.maxPatternDepth(""));
assertEquals("Max depth for pattern *.exe", 0, scanner.maxPatternDepth("*.exe"));
assertEquals("Max depth for pattern **/*.exe", 50, scanner.maxPatternDepth("**/*.exe"));
assertEquals("Max depth for pattern build/*.exe", 1, scanner.maxPatternDepth("build/*.exe"));
assertEquals("Max depth for pattern build/foo/**/bar/*.exe", 50, scanner.maxPatternDepth("build/foo/**/bar/*.exe"));
assertEquals("Max depth for pattern foo/bar*/*.dll", 2, scanner.maxPatternDepth("foo/bar*/*.dll"));
assertEquals("Max depth for pattern *foo*/bar*/*.dll", 2, scanner.maxPatternDepth("*foo*/bar*/*.dll"));
}

@Test
public void testScanCurrentDirectory() throws Exception {
DirectoryScanner scanner = new DirectoryScanner();
List<Path> matches = scanner.scan("pom.xml");

assertEquals("number of matches", 1, matches.size());
assertEquals("match", new File("pom.xml"), matches.get(0).toFile());
}

@Test
public void testScanParentDirectory() throws Exception {
DirectoryScanner scanner = new DirectoryScanner();
List<Path> matches = scanner.scan("..\\pom.xml");

assertEquals("number of matches", 1, matches.size());
assertEquals("match", new File("../pom.xml"), matches.get(0).toFile());
}

@Test
public void testScanSubDirectory() throws Exception {
DirectoryScanner scanner = new DirectoryScanner();
List<Path> matches = scanner.scan("target/pom.xml");

assertEquals("number of matches", 0, matches.size());
}

@Test
public void testScanCurrentDirectoryWildcard() throws Exception {
DirectoryScanner scanner = new DirectoryScanner();
List<Path> matches = scanner.scan("*.xml");

assertEquals("number of matches", 1, matches.size());
assertEquals("match", new File("pom.xml"), matches.get(0).toFile());
}

@Test
public void testScanParentDirectoryWildcard() throws Exception {
DirectoryScanner scanner = new DirectoryScanner();
List<Path> matches = scanner.scan("..\\*.xml");

assertEquals("number of matches", 1, matches.size());
assertEquals("match", new File("../pom.xml"), matches.get(0).toFile());
}

@Test
public void testScanAbsoluteDirectoryWildcard() throws Exception {
DirectoryScanner scanner = new DirectoryScanner();
List<Path> matches = scanner.scan(new File("").getAbsolutePath() + "/*.xml");

assertEquals("number of matches", 1, matches.size());
assertEquals("match", new File(new File("").getAbsolutePath(), "pom.xml"), matches.get(0).toFile());
}

@Test
public void testScanCurrentDirectoryRecursively() throws Exception {
DirectoryScanner scanner = new DirectoryScanner();
List<Path> matches = scanner.scan("**/pom.xml");

assertEquals("number of matches", 1, matches.size());
assertEquals("match", new File("pom.xml"), matches.get(0).toFile());
}

@Test
public void testScanParentDirectoryRecursively() throws Exception {
DirectoryScanner scanner = new DirectoryScanner();
List<Path> matches = scanner.scan("../jsign-c*/**/pom.xml");

assertEquals("number of matches", 2, matches.size());
assertEquals("match", new File("../jsign-cli/pom.xml"), matches.get(0).toFile());
assertEquals("match", new File("../jsign-core/pom.xml"), matches.get(1).toFile());
}

@Test
public void testScanSubDirectoryRecursively() throws Exception {
DirectoryScanner scanner = new DirectoryScanner();
List<Path> matches = scanner.scan("../jsign-core/src/**/*.exe");

assertEquals("number of matches", 1, matches.size());
assertEquals("match", new File("../jsign-core/src/test/resources/wineyes.exe"), matches.get(0).toFile());
}

@Test
public void testScanAbsoluteDirectoryRecursively() throws Exception {
DirectoryScanner scanner = new DirectoryScanner();
List<Path> matches = scanner.scan(new File("..").getCanonicalPath() + "/jsign-core/src/**/*.exe");

assertEquals("number of matches", 1, matches.size());
assertEquals("match", new File(new File("..").getCanonicalPath(), "jsign-core/src/test/resources/wineyes.exe"), matches.get(0).toFile());
}
}
Loading

0 comments on commit aaaa43f

Please sign in to comment.