001/*
002 * Licensed to the Apache Software Foundation (ASF) under one or more
003 * contributor license agreements.  See the NOTICE file distributed with
004 * this work for additional information regarding copyright ownership.
005 * The ASF licenses this file to You under the Apache License, Version 2.0
006 * (the "License"); you may not use this file except in compliance with
007 * the License.  You may obtain a copy of the License at
008 *
009 *      http://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017package org.apache.camel.util;
018
019import java.io.BufferedInputStream;
020import java.io.BufferedOutputStream;
021import java.io.BufferedReader;
022import java.io.BufferedWriter;
023import java.io.ByteArrayInputStream;
024import java.io.Closeable;
025import java.io.File;
026import java.io.FileInputStream;
027import java.io.FileNotFoundException;
028import java.io.FileOutputStream;
029import java.io.IOException;
030import java.io.InputStream;
031import java.io.InputStreamReader;
032import java.io.OutputStream;
033import java.io.OutputStreamWriter;
034import java.io.Reader;
035import java.io.UnsupportedEncodingException;
036import java.io.Writer;
037import java.nio.ByteBuffer;
038import java.nio.CharBuffer;
039import java.nio.channels.FileChannel;
040import java.nio.channels.ReadableByteChannel;
041import java.nio.channels.WritableByteChannel;
042import java.nio.charset.Charset;
043import java.nio.charset.UnsupportedCharsetException;
044import java.nio.file.Files;
045import java.nio.file.Path;
046import java.util.function.Supplier;
047import java.util.stream.Stream;
048
049import org.slf4j.Logger;
050import org.slf4j.LoggerFactory;
051
052/**
053 * IO helper class.
054 */
055public final class IOHelper {
056
057    public static Supplier<Charset> defaultCharset = Charset::defaultCharset;
058
059    public static final int DEFAULT_BUFFER_SIZE = 1024 * 4;
060
061    public static final long INITIAL_OFFSET = 0;
062
063    private static final Logger LOG = LoggerFactory.getLogger(IOHelper.class);
064
065    // allows to turn on backwards compatible to turn off regarding the first
066    // read byte with value zero (0b0) as EOL.
067    // See more at CAMEL-11672
068    private static final boolean ZERO_BYTE_EOL_ENABLED
069            = "true".equalsIgnoreCase(System.getProperty("camel.zeroByteEOLEnabled", "true"));
070
071    private IOHelper() {
072        // Utility Class
073    }
074
075    /**
076     * Wraps the passed <code>in</code> into a {@link BufferedInputStream} object and returns that. If the passed
077     * <code>in</code> is already an instance of {@link BufferedInputStream} returns the same passed <code>in</code>
078     * reference as is (avoiding double wrapping).
079     *
080     * @param  in the wrapee to be used for the buffering support
081     * @return    the passed <code>in</code> decorated through a {@link BufferedInputStream} object as wrapper
082     */
083    public static BufferedInputStream buffered(InputStream in) {
084        return (in instanceof BufferedInputStream) ? (BufferedInputStream) in : new BufferedInputStream(in);
085    }
086
087    /**
088     * Wraps the passed <code>out</code> into a {@link BufferedOutputStream} object and returns that. If the passed
089     * <code>out</code> is already an instance of {@link BufferedOutputStream} returns the same passed <code>out</code>
090     * reference as is (avoiding double wrapping).
091     *
092     * @param  out the wrapee to be used for the buffering support
093     * @return     the passed <code>out</code> decorated through a {@link BufferedOutputStream} object as wrapper
094     */
095    public static BufferedOutputStream buffered(OutputStream out) {
096        return (out instanceof BufferedOutputStream) ? (BufferedOutputStream) out : new BufferedOutputStream(out);
097    }
098
099    /**
100     * Wraps the passed <code>reader</code> into a {@link BufferedReader} object and returns that. If the passed
101     * <code>reader</code> is already an instance of {@link BufferedReader} returns the same passed <code>reader</code>
102     * reference as is (avoiding double wrapping).
103     *
104     * @param  reader the wrapee to be used for the buffering support
105     * @return        the passed <code>reader</code> decorated through a {@link BufferedReader} object as wrapper
106     */
107    public static BufferedReader buffered(Reader reader) {
108        return (reader instanceof BufferedReader) ? (BufferedReader) reader : new BufferedReader(reader);
109    }
110
111    /**
112     * Wraps the passed <code>writer</code> into a {@link BufferedWriter} object and returns that. If the passed
113     * <code>writer</code> is already an instance of {@link BufferedWriter} returns the same passed <code>writer</code>
114     * reference as is (avoiding double wrapping).
115     *
116     * @param  writer the writer to be used for the buffering support
117     * @return        the passed <code>writer</code> decorated through a {@link BufferedWriter} object as wrapper
118     */
119    public static BufferedWriter buffered(Writer writer) {
120        return (writer instanceof BufferedWriter) ? (BufferedWriter) writer : new BufferedWriter(writer);
121    }
122
123    public static String toString(Reader reader) throws IOException {
124        return toString(reader, INITIAL_OFFSET);
125    }
126
127    public static String toString(Reader reader, long offset) throws IOException {
128        return toString(buffered(reader), offset);
129    }
130
131    public static String toString(BufferedReader reader) throws IOException {
132        return toString(reader, INITIAL_OFFSET);
133    }
134
135    public static String toString(BufferedReader reader, long offset) throws IOException {
136        StringBuilder sb = new StringBuilder(1024);
137
138        reader.skip(offset);
139
140        char[] buf = new char[1024];
141        try {
142            int len;
143            // read until we reach then end which is the -1 marker
144            while ((len = reader.read(buf)) != -1) {
145                sb.append(buf, 0, len);
146            }
147        } finally {
148            IOHelper.close(reader, "reader", LOG);
149        }
150
151        return sb.toString();
152    }
153
154    public static int copy(InputStream input, OutputStream output) throws IOException {
155        return copy(input, output, DEFAULT_BUFFER_SIZE);
156    }
157
158    public static int copy(final InputStream input, final OutputStream output, int bufferSize) throws IOException {
159        return copy(input, output, bufferSize, false);
160    }
161
162    public static int copy(final InputStream input, final OutputStream output, int bufferSize, boolean flushOnEachWrite)
163            throws IOException {
164        return copy(input, output, bufferSize, flushOnEachWrite, -1);
165    }
166
167    public static int copy(
168            final InputStream input, final OutputStream output, int bufferSize, boolean flushOnEachWrite,
169            long maxSize)
170            throws IOException {
171
172        if (input instanceof ByteArrayInputStream) {
173            // optimized for byte array as we only need the max size it can be
174            input.mark(0);
175            input.reset();
176            bufferSize = input.available();
177        } else {
178            int avail = input.available();
179            if (avail > bufferSize) {
180                bufferSize = avail;
181            }
182        }
183
184        if (bufferSize > 262144) {
185            // upper cap to avoid buffers too big
186            bufferSize = 262144;
187        }
188
189        if (LOG.isTraceEnabled()) {
190            LOG.trace("Copying InputStream: {} -> OutputStream: {} with buffer: {} and flush on each write {}", input, output,
191                    bufferSize, flushOnEachWrite);
192        }
193
194        int total = 0;
195        final byte[] buffer = new byte[bufferSize];
196        int n = input.read(buffer);
197
198        boolean hasData;
199        if (ZERO_BYTE_EOL_ENABLED) {
200            // workaround issue on some application servers which can return 0
201            // (instead of -1)
202            // as first byte to indicate end of stream (CAMEL-11672)
203            hasData = n > 0;
204        } else {
205            hasData = n > -1;
206        }
207        if (hasData) {
208            while (-1 != n) {
209                output.write(buffer, 0, n);
210                if (flushOnEachWrite) {
211                    output.flush();
212                }
213                total += n;
214                if (maxSize > 0 && total > maxSize) {
215                    throw new IOException("The InputStream entry being copied exceeds the maximum allowed size");
216                }
217                n = input.read(buffer);
218            }
219        }
220        if (!flushOnEachWrite) {
221            // flush at end, if we didn't do it during the writing
222            output.flush();
223        }
224        return total;
225    }
226
227    public static void copyAndCloseInput(InputStream input, OutputStream output) throws IOException {
228        copyAndCloseInput(input, output, DEFAULT_BUFFER_SIZE);
229    }
230
231    public static void copyAndCloseInput(InputStream input, OutputStream output, int bufferSize) throws IOException {
232        copy(input, output, bufferSize);
233        close(input, null, LOG);
234    }
235
236    public static int copy(final Reader input, final Writer output, int bufferSize) throws IOException {
237        final char[] buffer = new char[bufferSize];
238        int n = input.read(buffer);
239        int total = 0;
240        while (-1 != n) {
241            output.write(buffer, 0, n);
242            total += n;
243            n = input.read(buffer);
244        }
245        output.flush();
246        return total;
247    }
248
249    public static void transfer(ReadableByteChannel input, WritableByteChannel output) throws IOException {
250        ByteBuffer buffer = ByteBuffer.allocate(DEFAULT_BUFFER_SIZE);
251        while (input.read(buffer) >= 0) {
252            buffer.flip();
253            while (buffer.hasRemaining()) {
254                output.write(buffer);
255            }
256            buffer.clear();
257        }
258    }
259
260    /**
261     * Forces any updates to this channel's file to be written to the storage device that contains it.
262     *
263     * @param channel the file channel
264     * @param name    the name of the resource
265     * @param log     the log to use when reporting warnings, will use this class's own {@link Logger} if
266     *                <tt>log == null</tt>
267     */
268    public static void force(FileChannel channel, String name, Logger log) {
269        try {
270            if (channel != null) {
271                channel.force(true);
272            }
273        } catch (Exception e) {
274            if (log == null) {
275                // then fallback to use the own Logger
276                log = LOG;
277            }
278            if (name != null) {
279                log.warn("Cannot force FileChannel: {}. Reason: {}", name, e.getMessage(), e);
280            } else {
281                log.warn("Cannot force FileChannel. Reason: {}", e.getMessage(), e);
282            }
283        }
284    }
285
286    /**
287     * Forces any updates to a FileOutputStream be written to the storage device that contains it.
288     *
289     * @param os   the file output stream
290     * @param name the name of the resource
291     * @param log  the log to use when reporting warnings, will use this class's own {@link Logger} if
292     *             <tt>log == null</tt>
293     */
294    public static void force(FileOutputStream os, String name, Logger log) {
295        try {
296            if (os != null) {
297                os.getFD().sync();
298            }
299        } catch (Exception e) {
300            if (log == null) {
301                // then fallback to use the own Logger
302                log = LOG;
303            }
304            if (name != null) {
305                log.warn("Cannot sync FileDescriptor: {}. Reason: {}", name, e.getMessage(), e);
306            } else {
307                log.warn("Cannot sync FileDescriptor. Reason: {}", e.getMessage(), e);
308            }
309        }
310    }
311
312    /**
313     * Closes the given writer, logging any closing exceptions to the given log. An associated FileOutputStream can
314     * optionally be forced to disk.
315     *
316     * @param writer the writer to close
317     * @param os     an underlying FileOutputStream that will to be forced to disk according to the force parameter
318     * @param name   the name of the resource
319     * @param log    the log to use when reporting warnings, will use this class's own {@link Logger} if
320     *               <tt>log == null</tt>
321     * @param force  forces the FileOutputStream to disk
322     */
323    public static void close(Writer writer, FileOutputStream os, String name, Logger log, boolean force) {
324        if (writer != null && force) {
325            // flush the writer prior to syncing the FD
326            try {
327                writer.flush();
328            } catch (Exception e) {
329                if (log == null) {
330                    // then fallback to use the own Logger
331                    log = LOG;
332                }
333                if (name != null) {
334                    log.warn("Cannot flush Writer: {}. Reason: {}", name, e.getMessage(), e);
335                } else {
336                    log.warn("Cannot flush Writer. Reason: {}", e.getMessage(), e);
337                }
338            }
339            force(os, name, log);
340        }
341        close(writer, name, log);
342    }
343
344    /**
345     * Closes the given resource if it is available, logging any closing exceptions to the given log.
346     *
347     * @param closeable the object to close
348     * @param name      the name of the resource
349     * @param log       the log to use when reporting closure warnings, will use this class's own {@link Logger} if
350     *                  <tt>log == null</tt>
351     */
352    public static void close(Closeable closeable, String name, Logger log) {
353        if (closeable != null) {
354            try {
355                closeable.close();
356            } catch (IOException e) {
357                if (log == null) {
358                    // then fallback to use the own Logger
359                    log = LOG;
360                }
361                if (name != null) {
362                    log.warn("Cannot close: {}. Reason: {}", name, e.getMessage(), e);
363                } else {
364                    log.warn("Cannot close. Reason: {}", e.getMessage(), e);
365                }
366            }
367        }
368    }
369
370    /**
371     * Closes the given resource if it is available and don't catch the exception
372     *
373     * @param  closeable   the object to close
374     * @throws IOException
375     */
376    public static void closeWithException(Closeable closeable) throws IOException {
377        if (closeable != null) {
378            closeable.close();
379        }
380    }
381
382    /**
383     * Closes the given channel if it is available, logging any closing exceptions to the given log. The file's channel
384     * can optionally be forced to disk.
385     *
386     * @param channel the file channel
387     * @param name    the name of the resource
388     * @param log     the log to use when reporting warnings, will use this class's own {@link Logger} if
389     *                <tt>log == null</tt>
390     * @param force   forces the file channel to disk
391     */
392    public static void close(FileChannel channel, String name, Logger log, boolean force) {
393        if (force) {
394            force(channel, name, log);
395        }
396        close(channel, name, log);
397    }
398
399    /**
400     * Closes the given resource if it is available.
401     *
402     * @param closeable the object to close
403     * @param name      the name of the resource
404     */
405    public static void close(Closeable closeable, String name) {
406        close(closeable, name, LOG);
407    }
408
409    /**
410     * Closes the given resource if it is available.
411     *
412     * @param closeable the object to close
413     */
414    public static void close(Closeable closeable) {
415        close(closeable, null, LOG);
416    }
417
418    /**
419     * Closes the given resources if they are available.
420     *
421     * @param closeables the objects to close
422     */
423    public static void close(Closeable... closeables) {
424        for (Closeable closeable : closeables) {
425            close(closeable);
426        }
427    }
428
429    public static void closeIterator(Object it) throws IOException {
430        if (it instanceof Closeable) {
431            IOHelper.closeWithException((Closeable) it);
432        }
433        if (it instanceof java.util.Scanner) {
434            IOException ioException = ((java.util.Scanner) it).ioException();
435            if (ioException != null) {
436                throw ioException;
437            }
438        }
439    }
440
441    public static void validateCharset(String charset) throws UnsupportedCharsetException {
442        if (charset != null) {
443            if (Charset.isSupported(charset)) {
444                Charset.forName(charset);
445                return;
446            }
447        }
448        throw new UnsupportedCharsetException(charset);
449    }
450
451    /**
452     * Loads the entire stream into memory as a String and returns it.
453     * <p/>
454     * <b>Notice:</b> This implementation appends a <tt>\n</tt> as line terminator at the of the text.
455     * <p/>
456     * Warning, don't use for crazy big streams :)
457     */
458    public static String loadText(InputStream in) throws IOException {
459        StringBuilder builder = new StringBuilder();
460        InputStreamReader isr = new InputStreamReader(in);
461        try {
462            BufferedReader reader = buffered(isr);
463            while (true) {
464                String line = reader.readLine();
465                if (line != null) {
466                    builder.append(line);
467                    builder.append("\n");
468                } else {
469                    break;
470                }
471            }
472            return builder.toString();
473        } finally {
474            close(isr, in);
475        }
476    }
477
478    /**
479     * Loads the entire stream into memory as a String and returns the given line number.
480     * <p/>
481     * Warning, don't use for crazy big streams :)
482     */
483    public static String loadTextLine(InputStream in, int lineNumber) throws IOException {
484        int i = 0;
485        InputStreamReader isr = new InputStreamReader(in);
486        try {
487            BufferedReader reader = buffered(isr);
488            while (true) {
489                String line = reader.readLine();
490                if (line != null) {
491                    i++;
492                    if (i >= lineNumber) {
493                        return line;
494                    }
495                } else {
496                    break;
497                }
498            }
499        } finally {
500            close(isr, in);
501        }
502
503        return null;
504    }
505
506    /**
507     * Appends the text to the file.
508     */
509    public static void appendText(String text, File file) throws IOException {
510        doWriteText(text, file, true);
511    }
512
513    /**
514     * Writes the text to the file.
515     */
516    public static void writeText(String text, File file) throws IOException {
517        doWriteText(text, file, false);
518    }
519
520    private static void doWriteText(String text, File file, boolean append) throws IOException {
521        if (!file.exists()) {
522            String path = FileUtil.onlyPath(file.getPath());
523            if (path != null) {
524                new File(path).mkdirs();
525            }
526        }
527        writeText(text, new FileOutputStream(file, append));
528    }
529
530    /**
531     * Writes the text to the stream.
532     */
533    public static void writeText(String text, OutputStream os) throws IOException {
534        try {
535            os.write(text.getBytes());
536        } finally {
537            close(os);
538        }
539    }
540
541    /**
542     * Get the charset name from the content type string
543     *
544     * @param  contentType the content type
545     * @return             the charset name, or <tt>UTF-8</tt> if no found
546     */
547    public static String getCharsetNameFromContentType(String contentType) {
548        // try optimized for direct match without using splitting
549        int pos = contentType.indexOf("charset=");
550        if (pos != -1) {
551            // special optimization for utf-8 which is a common charset
552            if (contentType.regionMatches(true, pos + 8, "utf-8", 0, 5)) {
553                return "UTF-8";
554            }
555
556            int end = contentType.indexOf(';', pos);
557            String charset;
558            if (end > pos) {
559                charset = contentType.substring(pos + 8, end);
560            } else {
561                charset = contentType.substring(pos + 8);
562            }
563            return normalizeCharset(charset);
564        }
565
566        String[] values = contentType.split(";");
567        for (String value : values) {
568            value = value.trim();
569            // Perform a case insensitive "startsWith" check that works for different locales
570            String prefix = "charset=";
571            if (value.regionMatches(true, 0, prefix, 0, prefix.length())) {
572                // Take the charset name
573                String charset = value.substring(8);
574                return normalizeCharset(charset);
575            }
576        }
577        // use UTF-8 as default
578        return "UTF-8";
579    }
580
581    /**
582     * This method will take off the quotes and double quotes of the charset
583     */
584    public static String normalizeCharset(String charset) {
585        if (charset != null) {
586            boolean trim = false;
587            String answer = charset.trim();
588            if (answer.startsWith("'") || answer.startsWith("\"")) {
589                answer = answer.substring(1);
590                trim = true;
591            }
592            if (answer.endsWith("'") || answer.endsWith("\"")) {
593                answer = answer.substring(0, answer.length() - 1);
594                trim = true;
595            }
596            return trim ? answer.trim() : answer;
597        } else {
598            return null;
599        }
600    }
601
602    /**
603     * Lookup the OS environment variable in a safe manner by using upper case keys and underscore instead of dash.
604     */
605    public static String lookupEnvironmentVariable(String key) {
606        // lookup OS env with upper case key
607        String upperKey = key.toUpperCase();
608        String value = System.getenv(upperKey);
609
610        if (value == null) {
611            // some OS do not support dashes in keys, so replace with underscore
612            String normalizedKey = upperKey.replace('-', '_');
613
614            // and replace dots with underscores so keys like my.key are
615            // translated to MY_KEY
616            normalizedKey = normalizedKey.replace('.', '_');
617
618            value = System.getenv(normalizedKey);
619        }
620        return value;
621    }
622
623    /**
624     * Encoding-aware input stream.
625     */
626    public static class EncodingInputStream extends InputStream {
627
628        private final File file;
629        private final BufferedReader reader;
630        private final Charset defaultStreamCharset;
631
632        private ByteBuffer bufferBytes;
633        private final CharBuffer bufferedChars = CharBuffer.allocate(4096);
634
635        public EncodingInputStream(File file, String charset) throws IOException {
636            this.file = file;
637            reader = toReader(file, charset);
638            defaultStreamCharset = defaultCharset.get();
639        }
640
641        @Override
642        public int read() throws IOException {
643            if (bufferBytes == null || bufferBytes.remaining() <= 0) {
644                BufferCaster.cast(bufferedChars).clear();
645                int len = reader.read(bufferedChars);
646                bufferedChars.flip();
647                if (len == -1) {
648                    return -1;
649                }
650                bufferBytes = defaultStreamCharset.encode(bufferedChars);
651            }
652            return bufferBytes.get() & 0xFF;
653        }
654
655        @Override
656        public void close() throws IOException {
657            reader.close();
658        }
659
660        @Override
661        public synchronized void reset() throws IOException {
662            reader.reset();
663        }
664
665        public InputStream toOriginalInputStream() throws FileNotFoundException {
666            return new FileInputStream(file);
667        }
668    }
669
670    /**
671     * Encoding-aware file reader.
672     */
673    public static class EncodingFileReader extends InputStreamReader {
674
675        private final FileInputStream in;
676
677        /**
678         * @param in      file to read
679         * @param charset character set to use
680         */
681        public EncodingFileReader(FileInputStream in, String charset) throws UnsupportedEncodingException {
682            super(in, charset);
683            this.in = in;
684        }
685
686        /**
687         * @param in      file to read
688         * @param charset character set to use
689         */
690        public EncodingFileReader(FileInputStream in, Charset charset) {
691            super(in, charset);
692            this.in = in;
693        }
694
695        @Override
696        public void close() throws IOException {
697            try {
698                super.close();
699            } finally {
700                in.close();
701            }
702        }
703    }
704
705    /**
706     * Encoding-aware file writer.
707     */
708    public static class EncodingFileWriter extends OutputStreamWriter {
709
710        private final FileOutputStream out;
711
712        /**
713         * @param out     file to write
714         * @param charset character set to use
715         */
716        public EncodingFileWriter(FileOutputStream out, String charset) throws UnsupportedEncodingException {
717            super(out, charset);
718            this.out = out;
719        }
720
721        /**
722         * @param out     file to write
723         * @param charset character set to use
724         */
725        public EncodingFileWriter(FileOutputStream out, Charset charset) {
726            super(out, charset);
727            this.out = out;
728        }
729
730        @Override
731        public void close() throws IOException {
732            try {
733                super.close();
734            } finally {
735                out.close();
736            }
737        }
738    }
739
740    /**
741     * Converts the given {@link File} with the given charset to {@link InputStream} with the JVM default charset
742     *
743     * @param  file    the file to be converted
744     * @param  charset the charset the file is read with
745     * @return         the input stream with the JVM default charset
746     */
747    public static InputStream toInputStream(File file, String charset) throws IOException {
748        if (charset != null) {
749            return new EncodingInputStream(file, charset);
750        } else {
751            return buffered(new FileInputStream(file));
752        }
753    }
754
755    public static BufferedReader toReader(File file, String charset) throws IOException {
756        FileInputStream in = new FileInputStream(file);
757        return IOHelper.buffered(new EncodingFileReader(in, charset));
758    }
759
760    public static BufferedReader toReader(File file, Charset charset) throws IOException {
761        FileInputStream in = new FileInputStream(file);
762        return IOHelper.buffered(new EncodingFileReader(in, charset));
763    }
764
765    public static BufferedWriter toWriter(FileOutputStream os, String charset) throws IOException {
766        return IOHelper.buffered(new EncodingFileWriter(os, charset));
767    }
768
769    public static BufferedWriter toWriter(FileOutputStream os, Charset charset) {
770        return IOHelper.buffered(new EncodingFileWriter(os, charset));
771    }
772
773    /**
774     * Reads the file under the given {@code path}, strips lines starting with {@code commentPrefix} and optionally also
775     * strips blank lines (the ones for which {@link String#isBlank()} returns {@code true}. Normalizes EOL characters
776     * to {@code '\n'}.
777     *
778     * @param  path            the path of the file to read
779     * @param  commentPrefix   the leading character sequence of comment lines.
780     * @param  stripBlankLines if true {@code true} the lines matching {@link String#isBlank()} will not appear in the
781     *                         result
782     * @return                 the filtered content of the file
783     */
784    public static String stripLineComments(Path path, String commentPrefix, boolean stripBlankLines) {
785        StringBuilder result = new StringBuilder();
786        try (Stream<String> lines = Files.lines(path)) {
787            lines
788                    .filter(l -> stripBlankLines ? !l.isBlank() : true)
789                    .filter(line -> !line.startsWith(commentPrefix))
790                    .forEach(line -> result.append(line).append('\n'));
791        } catch (IOException e) {
792            throw new RuntimeException("Cannot read file: " + path, e);
793        }
794        return result.toString();
795    }
796
797}