diff options
Diffstat (limited to 'vespaclient-java/src/main')
10 files changed, 601 insertions, 0 deletions
diff --git a/vespaclient-java/src/main/java/com/yahoo/vespa/security/tool/CliOptions.java b/vespaclient-java/src/main/java/com/yahoo/vespa/security/tool/CliOptions.java new file mode 100644 index 00000000000..7560c5f3b4c --- /dev/null +++ b/vespaclient-java/src/main/java/com/yahoo/vespa/security/tool/CliOptions.java @@ -0,0 +1,65 @@ +// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.security.tool; + +import org.apache.commons.cli.HelpFormatter; +import org.apache.commons.cli.Option; +import org.apache.commons.cli.Options; + +import java.io.PrintStream; +import java.io.PrintWriter; +import java.util.List; +import java.util.stream.Collectors; + +/** + * @author vekterli + * @author bjorncs + */ +class CliOptions { + + private static final Option HELP_OPTION = Option.builder("h") + .longOpt("help") + .hasArg(false) + .required(false) + .desc("Show help") + .build(); + + static Options withHelpOption(List<Option> options) { + var optionsWithHelp = new Options(); + options.forEach(optionsWithHelp::addOption); + optionsWithHelp.addOption(HELP_OPTION); + return optionsWithHelp; + } + + static void printTopLevelHelp(PrintStream out, List<Tool> tools) { + var formatter = new HelpFormatter(); + var writer = new PrintWriter(out); + formatter.printHelp( + writer, + formatter.getWidth(), + "vespa-security <tool> [TOOL OPTIONS]", + "Where <tool> is one of: %s".formatted(tools.stream().map(Tool::name).collect(Collectors.joining(", "))), + withHelpOption(List.of()), + formatter.getLeftPadding(), + formatter.getDescPadding(), + "Invoke vespa-security <tool> --help for tool-specific help"); + writer.flush(); + } + + static void printToolSpecificHelp(PrintStream out, String toolName, + ToolDescription toolDesc, + Options optionsWithHelp) { + var formatter = new HelpFormatter(); + var writer = new PrintWriter(out); + formatter.printHelp( + writer, + formatter.getWidth(), + "vespa-security %s %s".formatted(toolName, toolDesc.helpArgSuffix()), + toolDesc.helpHeader(), + optionsWithHelp, + formatter.getLeftPadding(), + formatter.getDescPadding(), + toolDesc.helpFooter()); + writer.flush(); + } +} + diff --git a/vespaclient-java/src/main/java/com/yahoo/vespa/security/tool/CliUtils.java b/vespaclient-java/src/main/java/com/yahoo/vespa/security/tool/CliUtils.java new file mode 100644 index 00000000000..e9b348ab2a2 --- /dev/null +++ b/vespaclient-java/src/main/java/com/yahoo/vespa/security/tool/CliUtils.java @@ -0,0 +1,19 @@ +// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.security.tool; + +import org.apache.commons.cli.CommandLine; + +/** + * @author vekterli + */ +public class CliUtils { + + public static String optionOrThrow(CommandLine arguments, String option) { + var value = arguments.getOptionValue(option); + if (value == null) { + throw new IllegalArgumentException("Required argument '--%s' must be provided".formatted(option)); + } + return value; + } + +} diff --git a/vespaclient-java/src/main/java/com/yahoo/vespa/security/tool/Main.java b/vespaclient-java/src/main/java/com/yahoo/vespa/security/tool/Main.java new file mode 100644 index 00000000000..4216ffb6ed4 --- /dev/null +++ b/vespaclient-java/src/main/java/com/yahoo/vespa/security/tool/Main.java @@ -0,0 +1,104 @@ +// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.security.tool; + +import com.yahoo.vespa.security.tool.crypto.DecryptTool; +import com.yahoo.vespa.security.tool.crypto.EncryptTool; +import com.yahoo.vespa.security.tool.crypto.KeygenTool; +import org.apache.commons.cli.CommandLine; +import org.apache.commons.cli.CommandLineParser; +import org.apache.commons.cli.DefaultParser; +import org.apache.commons.cli.Options; +import org.apache.commons.cli.ParseException; + +import java.io.PrintStream; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * Primary application entry point for security utility tools. Handles tool selection, + * CLI argument parsing and exception printing. + * + * Based on previous vespa-security-env tool. + * + * @author vekterli + * @author bjorncs + */ +public class Main { + + private final PrintStream stdOut; + private final PrintStream stdError; + + Main(PrintStream stdOut, PrintStream stdError) { + this.stdOut = stdOut; + this.stdError = stdError; + } + + public static void main(String[] args) { + var program = new Main(System.out, System.err); + int returnCode = program.execute(args, System.getenv()); + System.exit(returnCode); + } + + private static final List<Tool> TOOLS = List.of( + new KeygenTool(), new EncryptTool(), new DecryptTool()); + + private static Optional<Tool> toolFromCliArgs(String[] args) { + if (args.length == 0) { + return Optional.empty(); + } + String toolName = args[0]; + return TOOLS.stream().filter(t -> t.name().equals(toolName)).findFirst(); + } + + private static String[] withToolNameArgRemoved(String[] args) { + if (args.length == 0) { + throw new IllegalArgumentException("Argument array did not contain a tool name"); + } + String[] truncatedArgs = new String[args.length - 1]; + System.arraycopy(args, 1, truncatedArgs, 0, truncatedArgs.length); + return truncatedArgs; + } + + private static CommandLine parseCliArguments(String[] cliArgs, Options options) throws ParseException { + CommandLineParser parser = new DefaultParser(); + return parser.parse(options, cliArgs); + } + + public int execute(String[] args, Map<String, String> envVars) { + boolean debugMode = envVars.containsKey("VESPA_DEBUG"); + try { + var maybeTool = toolFromCliArgs(args); + if (maybeTool.isEmpty()) { // This also implicitly covers the top-level --help case. + CliOptions.printTopLevelHelp(stdOut, TOOLS); + return 0; + } + var tool = maybeTool.get(); + var toolDesc = tool.description(); + var cliOpts = CliOptions.withHelpOption(toolDesc.cliOptions()); + String[] truncatedArgs = withToolNameArgRemoved(args); + var cmdLine = parseCliArguments(truncatedArgs, cliOpts); + if (cmdLine.hasOption("help")) { + CliOptions.printToolSpecificHelp(stdOut, tool.name(), toolDesc, cliOpts); + return 0; + } + var invocation = new ToolInvocation(cmdLine, envVars, stdOut, stdError, debugMode); + return tool.invoke(invocation); + } catch (ParseException e) { + return handleException("Failed to parse command line arguments: " + e.getMessage(), e, debugMode); + } catch (IllegalArgumentException e) { + return handleException("Invalid command line arguments: " + e.getMessage(), e, debugMode); + } catch (Exception e) { + return handleException("Got unhandled exception: " + e.getMessage(), e, debugMode); + } + } + + private int handleException(String message, Exception exception, boolean debugMode) { + stdError.println(message); + if (debugMode) { + exception.printStackTrace(stdError); + } + return 1; + } + +} diff --git a/vespaclient-java/src/main/java/com/yahoo/vespa/security/tool/Tool.java b/vespaclient-java/src/main/java/com/yahoo/vespa/security/tool/Tool.java new file mode 100644 index 00000000000..251b2d40e3c --- /dev/null +++ b/vespaclient-java/src/main/java/com/yahoo/vespa/security/tool/Tool.java @@ -0,0 +1,30 @@ +// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.security.tool; + +/** + * A named tool that can be invoked via the parent Main program. + * + * @author vekterli + */ +public interface Tool { + + /** + * Name of the tool used verbatim on the command line. + */ + String name(); + + /** + * Description used when "--help" is invoked for a particular tool + */ + ToolDescription description(); + + /** + * Invokes the tool logic with a ToolInvocation that encapsulates the command line + * and input/ouput environment the tool was called in. + * + * @param invocation parameters and environment to be used by the tool + * @return exit code that will be returned by the main process + */ + int invoke(ToolInvocation invocation); + +} diff --git a/vespaclient-java/src/main/java/com/yahoo/vespa/security/tool/ToolDescription.java b/vespaclient-java/src/main/java/com/yahoo/vespa/security/tool/ToolDescription.java new file mode 100644 index 00000000000..168d8fba3ba --- /dev/null +++ b/vespaclient-java/src/main/java/com/yahoo/vespa/security/tool/ToolDescription.java @@ -0,0 +1,19 @@ +// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.security.tool; + +import org.apache.commons.cli.Option; + +import java.util.List; + +/** + * Used by Tool subclasses to describe their options and calling semantics via + * the "--help" output from the Main program. + * + * @author vekterli + */ +public record ToolDescription(String helpArgSuffix, + String helpHeader, + String helpFooter, + List<Option> cliOptions) { + +} diff --git a/vespaclient-java/src/main/java/com/yahoo/vespa/security/tool/ToolInvocation.java b/vespaclient-java/src/main/java/com/yahoo/vespa/security/tool/ToolInvocation.java new file mode 100644 index 00000000000..b7340ebb749 --- /dev/null +++ b/vespaclient-java/src/main/java/com/yahoo/vespa/security/tool/ToolInvocation.java @@ -0,0 +1,18 @@ +// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.security.tool; + +import org.apache.commons.cli.CommandLine; + +import java.io.PrintStream; +import java.util.Map; + +/** + * @author vekterli + */ +public record ToolInvocation(CommandLine arguments, + Map<String, String> envVars, + PrintStream stdOut, + PrintStream stdError, + boolean debugMode) { + +} diff --git a/vespaclient-java/src/main/java/com/yahoo/vespa/security/tool/crypto/CipherUtils.java b/vespaclient-java/src/main/java/com/yahoo/vespa/security/tool/crypto/CipherUtils.java new file mode 100644 index 00000000000..e3954558026 --- /dev/null +++ b/vespaclient-java/src/main/java/com/yahoo/vespa/security/tool/crypto/CipherUtils.java @@ -0,0 +1,37 @@ +// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.security.tool.crypto; + +import javax.crypto.Cipher; +import javax.crypto.CipherOutputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +/** + * @author vekterli + */ +public class CipherUtils { + + /** + * Streams the contents of fromPath into toPath after being wrapped by the input cipher. + * Depending on the Cipher mode, this either encrypts a plaintext file into ciphertext, + * or decrypts a ciphertext file into plaintext. + * + * @param fromPath source file path to read from + * @param toPath destination file path to write to + * @param cipher a Cipher in either ENCRYPT or DECRYPT mode + * @throws IOException if any file operation fails + */ + public static void streamEncipherFileContents(Path fromPath, Path toPath, Cipher cipher) throws IOException { + if (fromPath.equals(toPath)) { + throw new IllegalArgumentException("Can't use same file as both input and output for enciphering"); + } + try (var inStream = Files.newInputStream(fromPath); + var outStream = Files.newOutputStream(toPath); + var cipherStream = new CipherOutputStream(outStream, cipher)) { + inStream.transferTo(cipherStream); + cipherStream.flush(); + } + } + +} diff --git a/vespaclient-java/src/main/java/com/yahoo/vespa/security/tool/crypto/DecryptTool.java b/vespaclient-java/src/main/java/com/yahoo/vespa/security/tool/crypto/DecryptTool.java new file mode 100644 index 00000000000..b307ab76da8 --- /dev/null +++ b/vespaclient-java/src/main/java/com/yahoo/vespa/security/tool/crypto/DecryptTool.java @@ -0,0 +1,111 @@ +// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.security.tool.crypto; + +import com.yahoo.security.KeyUtils; +import com.yahoo.security.SealedSharedKey; +import com.yahoo.security.SharedKeyGenerator; +import com.yahoo.vespa.security.tool.CliUtils; +import com.yahoo.vespa.security.tool.Tool; +import com.yahoo.vespa.security.tool.ToolDescription; +import com.yahoo.vespa.security.tool.ToolInvocation; +import org.apache.commons.cli.Option; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.List; +import java.util.Optional; + +/** + * Tooling for decrypting a file using a private key that corresponds to the public key used + * to originally encrypt the file. + * + * Uses the opaque token abstraction from {@link SharedKeyGenerator}. + * + * @author vekterli + */ +public class DecryptTool implements Tool { + + static final String OUTPUT_FILE_OPTION = "output-file"; + static final String RECIPIENT_PRIVATE_KEY_FILE_OPTION = "recipient-private-key-file"; + static final String KEY_ID_OPTION = "key-id"; + static final String TOKEN_OPTION = "token"; + + private static final List<Option> OPTIONS = List.of( + Option.builder("o") + .longOpt(OUTPUT_FILE_OPTION) + .hasArg(true) + .required(false) + .desc("Output file for decrypted plaintext") + .build(), + Option.builder("k") + .longOpt(RECIPIENT_PRIVATE_KEY_FILE_OPTION) + .hasArg(true) + .required(false) + .desc("Recipient private key file") + .build(), + Option.builder("i") + .longOpt(KEY_ID_OPTION) + .hasArg(true) + .required(false) + .desc("Numeric ID of recipient key. If this is not provided, " + + "the key ID stored as part of the token is not verified.") + .build(), + Option.builder("t") + .longOpt(TOKEN_OPTION) + .hasArg(true) + .required(false) + .desc("Token generated when the input file was encrypted") + .build()); + + @Override + public String name() { + return "decrypt"; + } + + @Override + public ToolDescription description() { + return new ToolDescription( + "<encrypted file> <options>", + "Decrypts a file using a provided token and a secret private key. The file must " + + "previously have been encrypted using the public key component of the given private key.", + "Note: this is a BETA tool version; its interface may be changed at any time", + OPTIONS); + } + + @Override + public int invoke(ToolInvocation invocation) { + try { + var arguments = invocation.arguments(); + var leftoverArgs = arguments.getArgs(); + if (leftoverArgs.length != 1) { + throw new IllegalArgumentException("Expected exactly 1 file argument to decrypt"); + } + var inputPath = Paths.get(leftoverArgs[0]); + if (!inputPath.toFile().exists()) { + throw new IllegalArgumentException("Cannot decrypt file '%s' as it does not exist".formatted(inputPath.toString())); + } + var maybeKeyId = Optional.ofNullable(arguments.hasOption(KEY_ID_OPTION) + ? Integer.parseInt(arguments.getOptionValue(KEY_ID_OPTION)) + : null); + var outputPath = Paths.get(CliUtils.optionOrThrow(arguments, OUTPUT_FILE_OPTION)); + var privKeyPath = Paths.get(CliUtils.optionOrThrow(arguments, RECIPIENT_PRIVATE_KEY_FILE_OPTION)); + var tokenString = CliUtils.optionOrThrow(arguments, TOKEN_OPTION); + var sealedSharedKey = SealedSharedKey.fromTokenString(tokenString.strip()); + if (maybeKeyId.isPresent() && (maybeKeyId.get() != sealedSharedKey.keyId())) { + throw new IllegalArgumentException(("Key ID specified with --key-id (%d) does not match key ID " + + "used when generating the supplied token (%d)") + .formatted(maybeKeyId.get(), sealedSharedKey.keyId())); + } + var privateKey = KeyUtils.fromBase64EncodedX25519PrivateKey(Files.readString(privKeyPath).strip()); + var secretShared = SharedKeyGenerator.fromSealedKey(sealedSharedKey, privateKey); + var cipher = SharedKeyGenerator.makeAesGcmDecryptionCipher(secretShared); + + CipherUtils.streamEncipherFileContents(inputPath, outputPath, cipher); + + } catch (IOException e) { + throw new RuntimeException(e); + } + return 0; + } +} diff --git a/vespaclient-java/src/main/java/com/yahoo/vespa/security/tool/crypto/EncryptTool.java b/vespaclient-java/src/main/java/com/yahoo/vespa/security/tool/crypto/EncryptTool.java new file mode 100644 index 00000000000..5d6cee2fabc --- /dev/null +++ b/vespaclient-java/src/main/java/com/yahoo/vespa/security/tool/crypto/EncryptTool.java @@ -0,0 +1,93 @@ +// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.security.tool.crypto; + +import com.yahoo.security.KeyUtils; +import com.yahoo.security.SharedKeyGenerator; +import com.yahoo.vespa.security.tool.CliUtils; +import com.yahoo.vespa.security.tool.Tool; +import com.yahoo.vespa.security.tool.ToolDescription; +import com.yahoo.vespa.security.tool.ToolInvocation; +import org.apache.commons.cli.Option; + +import java.io.IOException; +import java.nio.file.Paths; +import java.util.List; + +/** + * Tooling to encrypt a file using a public key, emitting a non-secret token that can be + * passed on to a recipient holding the corresponding private key. + * + * Uses the opaque token abstraction from {@link SharedKeyGenerator}. + * + * @author vekterli + */ +public class EncryptTool implements Tool { + + static final String OUTPUT_FILE_OPTION = "output-file"; + static final String KEY_ID_OPTION = "key-id"; + static final String RECIPIENT_PUBLIC_KEY_OPTION = "recipient-public-key"; + + private static final List<Option> OPTIONS = List.of( + Option.builder("o") + .longOpt(OUTPUT_FILE_OPTION) + .hasArg(true) + .required(false) + .desc("Output file (will be truncated if it already exists)") + .build(), + Option.builder("r") + .longOpt(RECIPIENT_PUBLIC_KEY_OPTION) + .hasArg(true) + .required(false) + .desc("Recipient X25519 public key in Base64 encoded format") + .build(), + Option.builder("i") + .longOpt(KEY_ID_OPTION) + .hasArg(true) + .required(false) + .desc("Numeric ID of recipient key") + .build()); + + @Override + public String name() { + return "encrypt"; + } + + @Override + public ToolDescription description() { + return new ToolDescription( + "<input file> <options>", + "One-way encrypts a file using the public key of a recipient. A public token is printed on " + + "standard out. The recipient can use this token to decrypt the file using their private key. " + + "The token does not have to be kept secret.", + "Note: this is a BETA tool version; its interface may be changed at any time", + OPTIONS); + } + + @Override + public int invoke(ToolInvocation invocation) { + try { + var arguments = invocation.arguments(); + var leftoverArgs = arguments.getArgs(); + if (leftoverArgs.length != 1) { + throw new IllegalArgumentException("Expected exactly 1 file argument to encrypt"); + } + var inputPath = Paths.get(leftoverArgs[0]); + if (!inputPath.toFile().exists()) { + throw new IllegalArgumentException("Cannot encrypt file '%s' as it does not exist".formatted(inputPath.toString())); + } + var outputPath = Paths.get(CliUtils.optionOrThrow(arguments, OUTPUT_FILE_OPTION)); + + var recipientPubKey = KeyUtils.fromBase64EncodedX25519PublicKey(CliUtils.optionOrThrow(arguments, RECIPIENT_PUBLIC_KEY_OPTION).strip()); + int keyId = Integer.parseInt(CliUtils.optionOrThrow(arguments, KEY_ID_OPTION)); + var shared = SharedKeyGenerator.generateForReceiverPublicKey(recipientPubKey, keyId); + var cipher = SharedKeyGenerator.makeAesGcmEncryptionCipher(shared); + + CipherUtils.streamEncipherFileContents(inputPath, outputPath, cipher); + + invocation.stdOut().println(shared.sealedSharedKey().toTokenString()); + } catch (IOException e) { + throw new RuntimeException(e); + } + return 0; + } +} diff --git a/vespaclient-java/src/main/java/com/yahoo/vespa/security/tool/crypto/KeygenTool.java b/vespaclient-java/src/main/java/com/yahoo/vespa/security/tool/crypto/KeygenTool.java new file mode 100644 index 00000000000..a0b9cce710b --- /dev/null +++ b/vespaclient-java/src/main/java/com/yahoo/vespa/security/tool/crypto/KeygenTool.java @@ -0,0 +1,105 @@ +// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.security.tool.crypto; + +import com.yahoo.security.KeyUtils; +import com.yahoo.vespa.security.tool.CliUtils; +import com.yahoo.vespa.security.tool.Tool; +import com.yahoo.vespa.security.tool.ToolDescription; +import com.yahoo.vespa.security.tool.ToolInvocation; +import org.apache.commons.cli.Option; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.attribute.PosixFilePermissions; +import java.security.interfaces.XECPrivateKey; +import java.security.interfaces.XECPublicKey; +import java.util.List; + +/** + * Tooling to generate random X25519 key pairs. + * + * @author vekterli + */ +public class KeygenTool implements Tool { + + static final String PRIVATE_OUT_FILE_OPTION = "private-out-file"; + static final String PUBLIC_OUT_FILE_OPTION = "public-out-file"; + static final String OVERWRITE_EXISTING_OPTION = "overwrite-existing"; + + private static final List<Option> OPTIONS = List.of( + Option.builder("k") + .longOpt(PRIVATE_OUT_FILE_OPTION) + .hasArg(true) + .required(false) + .desc("Output file for private (secret) key. Will be created with restrictive file permissions.") + .build(), + Option.builder("p") + .longOpt(PUBLIC_OUT_FILE_OPTION) + .hasArg(true) + .required(false) + .desc("Output file for public key") + .build(), + Option.builder() + .longOpt(OVERWRITE_EXISTING_OPTION) + .hasArg(false) + .required(false) + .desc("Overwrite existing key files instead of failing key generation if " + + "any files already exist. Use with great caution!") + .build()); + + @Override + public String name() { + return "keygen"; + } + + @Override + public ToolDescription description() { + return new ToolDescription( + "<options>", + "Generates an X25519 key pair and stores its private/public parts in " + + "separate files in Base64 encoded form.", + "Note: this is a BETA tool version; its interface may be changed at any time", + OPTIONS); + } + + private void handleExistingFileIfAny(Path filePath, boolean allowOverwrite) throws IOException { + if (filePath.toFile().exists()) { + if (!allowOverwrite) { + throw new IllegalArgumentException(("Output file '%s' already exists. No keys written. " + + "If you want to overwrite existing files, specify --%s.") + .formatted(filePath.toAbsolutePath().toString(), OVERWRITE_EXISTING_OPTION)); + } else { + // Explicitly delete the file since Files.createFile() will fail if it already exists. + Files.delete(filePath); + } + } + } + + @Override + public int invoke(ToolInvocation invocation) { + try { + var arguments = invocation.arguments(); + var privOutPath = Paths.get(CliUtils.optionOrThrow(arguments, PRIVATE_OUT_FILE_OPTION)); + var pubOutPath = Paths.get(CliUtils.optionOrThrow(arguments, PUBLIC_OUT_FILE_OPTION)); + + boolean allowOverwrite = arguments.hasOption(OVERWRITE_EXISTING_OPTION); + handleExistingFileIfAny(privOutPath, allowOverwrite); + handleExistingFileIfAny(pubOutPath, allowOverwrite); + + var keyPair = KeyUtils.generateX25519KeyPair(); + var privKey = (XECPrivateKey) keyPair.getPrivate(); + var pubKey = (XECPublicKey) keyPair.getPublic(); + + var privFilePerms = PosixFilePermissions.fromString("rw-------"); + Files.createFile( privOutPath, PosixFilePermissions.asFileAttribute(privFilePerms)); + Files.writeString(privOutPath, KeyUtils.toBase64EncodedX25519PrivateKey(privKey) + "\n"); + Files.writeString(pubOutPath, KeyUtils.toBase64EncodedX25519PublicKey(pubKey) + "\n"); + + } catch (IOException e) { + throw new RuntimeException(e); + } + return 0; + } +} |