001/*
002 * This library is part of OpenCms -
003 * the Open Source Content Management System
004 *
005 * Copyright (c) Alkacon Software GmbH & Co. KG (https://www.alkacon.com)
006 *
007 * This library is free software; you can redistribute it and/or
008 * modify it under the terms of the GNU Lesser General Public
009 * License as published by the Free Software Foundation; either
010 * version 2.1 of the License, or (at your option) any later version.
011 *
012 * This library is distributed in the hope that it will be useful,
013 * but WITHOUT ANY WARRANTY; without even the implied warranty of
014 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
015 * Lesser General Public License for more details.
016 *
017 * For further information about Alkacon Software GmbH & Co. KG, please see the
018 * company website: https://www.alkacon.com
019 *
020 * For further information about OpenCms, please see the
021 * project website: https://www.opencms.org
022 *
023 * You should have received a copy of the GNU Lesser General Public
024 * License along with this library; if not, write to the Free Software
025 * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
026 */
027
028package org.opencms.util;
029
030import org.opencms.file.CmsObject;
031import org.opencms.file.CmsProperty;
032import org.opencms.file.CmsPropertyDefinition;
033import org.opencms.file.CmsRequestContext;
034import org.opencms.file.CmsResource;
035import org.opencms.flex.CmsFlexCache;
036import org.opencms.i18n.CmsEncoder;
037import org.opencms.main.CmsException;
038import org.opencms.main.CmsIllegalArgumentException;
039import org.opencms.main.CmsLog;
040import org.opencms.main.CmsSystemInfo;
041import org.opencms.main.OpenCms;
042import org.opencms.staticexport.CmsLinkManager;
043
044import java.io.ByteArrayInputStream;
045import java.io.ByteArrayOutputStream;
046import java.io.File;
047import java.io.FileFilter;
048import java.io.FileInputStream;
049import java.io.FileNotFoundException;
050import java.io.FileOutputStream;
051import java.io.IOException;
052import java.io.InputStream;
053import java.io.OutputStream;
054import java.io.OutputStreamWriter;
055import java.net.URL;
056import java.util.ArrayList;
057import java.util.Arrays;
058import java.util.Collections;
059import java.util.Comparator;
060import java.util.Iterator;
061import java.util.List;
062import java.util.ListIterator;
063import java.util.Locale;
064
065import org.apache.commons.collections.Closure;
066import org.apache.commons.logging.Log;
067
068/**
069 * Provides File utility functions.<p>
070 *
071 * @since 6.0.0
072 */
073public final class CmsFileUtil {
074
075    /**
076     * Data bean which walkFileSystem passes to its callback.<p>
077     *
078     * The list of directories is mutable, which can be used by the callback to exclude certain directories.<p>
079     */
080    public static class FileWalkState {
081
082        /** Current directory. */
083        private File m_currentDir;
084
085        /** List of subdirectories of the current directory. */
086        private List<File> m_directories;
087
088        /** List of files of the current directory. */
089        private List<File> m_files;
090
091        /**
092         * Creates a new file walk state.<P>
093         *
094         * @param currentDir the current directory
095         * @param dirs the list of subdirectories
096         * @param files the list of files
097         */
098        public FileWalkState(File currentDir, List<File> dirs, List<File> files) {
099
100            m_currentDir = currentDir;
101            m_directories = dirs;
102            m_files = files;
103        }
104
105        /**
106         * Gets the current directory.<p>
107         *
108         * @return the current directory
109         */
110        public File getCurrentDir() {
111
112            return m_currentDir;
113        }
114
115        /**
116         * Gets the list of subdirectories.<p>
117         *
118         * @return the list of subdirectories
119         */
120        public List<File> getDirectories() {
121
122            return m_directories;
123        }
124
125        /**
126         * Returns the list of files.<p>
127         *
128         * @return the list of files
129         */
130        public List<File> getFiles() {
131
132            return m_files;
133        }
134    }
135
136    /** The static log object for this class. */
137    private static final Log LOG = CmsLog.getLog(CmsFileUtil.class);
138
139    /**
140     * Hides the public constructor.<p>
141     */
142    private CmsFileUtil() {
143
144        // empty
145    }
146
147    /**
148     * Adds a trailing separator to a path if required.<p>
149     *
150     * @param path the path to add the trailing separator to
151     * @return the path with a trailing separator
152     */
153    public static String addTrailingSeparator(String path) {
154
155        int l = path.length();
156        if ((l == 0) || (path.charAt(l - 1) != '/')) {
157            return path.concat("/");
158        } else {
159            return path;
160        }
161    }
162
163    /**
164     * Checks if all resources are present.<p>
165     *
166     * @param cms an initialized OpenCms user context which must have read access to all resources
167     * @param resources a list of vfs resource names to check
168     *
169     * @throws CmsIllegalArgumentException in case not all resources exist or can be read with the given OpenCms user context
170     */
171    public static void checkResources(CmsObject cms, List<String> resources) throws CmsIllegalArgumentException {
172
173        StringBuffer result = new StringBuffer(128);
174        ListIterator<String> it = resources.listIterator();
175        while (it.hasNext()) {
176            String resourcePath = it.next();
177            try {
178                CmsResource resource = cms.readResource(resourcePath);
179                // append folder separator, if resource is a folder and does not and with a slash
180                if (resource.isFolder() && !resourcePath.endsWith("/")) {
181                    it.set(resourcePath + "/");
182                }
183            } catch (@SuppressWarnings("unused") CmsException e) {
184                result.append(resourcePath);
185                result.append('\n');
186            }
187        }
188        if (result.length() > 0) {
189            throw new CmsIllegalArgumentException(
190                Messages.get().container(Messages.ERR_MISSING_RESOURCES_1, result.toString()));
191        }
192    }
193
194    /**
195     * Copies all data from an input stream to an output stream.<p>
196     *
197     * The streams are not closed by this method.<p>
198     *
199     * @param in the input stream
200     * @param out the output stream
201     *
202     * @throws IOException if copying fails
203     */
204    public static void copy(InputStream in, OutputStream out) throws IOException {
205
206        byte[] buffer = new byte[8192];
207        int read;
208        while ((read = in.read(buffer)) >= 0) {
209            if (read > 0) {
210                out.write(buffer, 0, read);
211            }
212        }
213    }
214
215    /**
216     * Simply version of a 1:1 binary file copy.<p>
217     *
218     * @param fromFile the name of the file to copy
219     * @param toFile the name of the target file
220     * @throws IOException if any IO error occurs during the copy operation
221     */
222    public static void copy(String fromFile, String toFile) throws IOException {
223
224        File inputFile = new File(fromFile);
225        File outputFile = new File(toFile);
226        if (!outputFile.getParentFile().isDirectory()) {
227            outputFile.getParentFile().mkdirs();
228        }
229        FileInputStream in = new FileInputStream(inputFile);
230        FileOutputStream out = new FileOutputStream(outputFile);
231
232        // transfer bytes from in to out
233        byte[] buf = new byte[1024];
234        int len;
235        while ((len = in.read(buf)) > 0) {
236            out.write(buf, 0, len);
237        }
238        in.close();
239        out.close();
240    }
241
242    /**
243     * Returns the formatted filesize to Bytes, KB, MB or GB depending on the given value.<p>
244     *
245     * @param filesize in bytes
246     * @param locale the locale of the current OpenCms user or the System's default locale if the first choice
247     *               is not at hand.
248     *
249     * @return the formatted filesize to Bytes, KB, MB or GB depending on the given value
250     */
251    public static String formatFilesize(long filesize, Locale locale) {
252
253        String result;
254        filesize = Math.abs(filesize);
255
256        if (Math.abs(filesize) < 1024) {
257            result = Messages.get().getBundle(locale).key(
258                Messages.GUI_FILEUTIL_FILESIZE_BYTES_1,
259                Long.valueOf(filesize));
260        } else if (Math.abs(filesize) < 1048576) {
261            // 1048576 = 1024.0 * 1024.0
262            result = Messages.get().getBundle(locale).key(
263                Messages.GUI_FILEUTIL_FILESIZE_KBYTES_1,
264                Double.valueOf(filesize / 1024.0));
265        } else if (Math.abs(filesize) < 1073741824) {
266            // 1024.0^3 =  1073741824
267            result = Messages.get().getBundle(locale).key(
268                Messages.GUI_FILEUTIL_FILESIZE_MBYTES_1,
269                Double.valueOf(filesize / 1048576.0));
270        } else {
271            result = Messages.get().getBundle(locale).key(
272                Messages.GUI_FILEUTIL_FILESIZE_GBYTES_1,
273                Double.valueOf(filesize / 1073741824.0));
274        }
275        return result;
276    }
277
278    /**
279     * Returns a comma separated list of resource paths names, with the site root
280     * from the given OpenCms user context removed.<p>
281     *
282     * @param context the current users OpenCms context (optional, may be <code>null</code>)
283     * @param resources a List of <code>{@link CmsResource}</code> instances to get the names from
284     *
285     * @return a comma separated list of resource paths names
286     */
287    public static String formatResourceNames(CmsRequestContext context, List<CmsResource> resources) {
288
289        if (resources == null) {
290            return null;
291        }
292        StringBuffer result = new StringBuffer(128);
293        Iterator<CmsResource> i = resources.iterator();
294        while (i.hasNext()) {
295            CmsResource res = i.next();
296            String path = res.getRootPath();
297            if (context != null) {
298                path = context.removeSiteRoot(path);
299            }
300            result.append(path);
301            if (i.hasNext()) {
302                result.append(", ");
303            }
304        }
305        return result.toString();
306    }
307
308    /**
309     * Returns the encoding of the file.
310     * Encoding is read from the content-encoding property and defaults to the systems default encoding.
311     * Since properties can change without rewriting content, the actual encoding can differ.
312     *
313     * @param cms {@link CmsObject} used to read properties of the given file.
314     * @param file the file for which the encoding is requested
315     * @return the file's encoding according to the content-encoding property, or the system's default encoding as default.
316     */
317    public static String getEncoding(CmsObject cms, CmsResource file) {
318
319        CmsProperty encodingProperty = CmsProperty.getNullProperty();
320        try {
321            encodingProperty = cms.readPropertyObject(file, CmsPropertyDefinition.PROPERTY_CONTENT_ENCODING, true);
322        } catch (CmsException e) {
323            LOG.debug(e.getLocalizedMessage(), e);
324        }
325        return CmsEncoder.lookupEncoding(encodingProperty.getValue(""), OpenCms.getSystemInfo().getDefaultEncoding());
326    }
327
328    /**
329     * Returns the extension of the given resource name, that is the part behind the last '.' char,
330     * converted to lower case letters.<p>
331     *
332     * The extension of a file is the part of the name after the last dot, including the dot.
333     * The extension of a folder is empty.
334     * All extensions are returned as lower case<p>
335     *
336     * Please note: No check is performed to ensure the given file name is not <code>null</code>.<p>
337     *
338     * Examples:<br>
339     * <ul>
340     *   <li><code>/folder.test/</code> has an empty extension.
341     *   <li><code>/folder.test/config</code> has an empty extension.
342     *   <li><code>/strange.filename.</code> has an empty extension.
343     *   <li><code>/document.PDF</code> has the extension <code>.pdf</code>.
344     * </ul>
345     *
346     * @param resourceName the resource to get the extension for
347     *
348     * @return the extension of a resource
349     */
350    public static String getExtension(String resourceName) {
351
352        // if the resource name indicates a folder
353        if (resourceName.charAt(resourceName.length() - 1) == '/') {
354            // folders have no extensions
355            return "";
356        }
357        // get just the name of the resource
358        String name = CmsResource.getName(resourceName);
359        // get the position of the last dot
360        int pos = name.lastIndexOf('.');
361        // if no dot or if no chars after the dot
362        if ((pos < 0) || ((pos + 1) == name.length())) {
363            return "";
364        }
365        // return the extension
366        return name.substring(pos).toLowerCase();
367    }
368
369    /**
370     * Returns the extension of the given file name, that is the part behind the last '.' char,
371     * converted to lower case letters.<p>
372     *
373     * The result does contain the '.' char. For example, if the input is <code>"opencms.html"</code>,
374     * then the result will be <code>".html"</code>.<p>
375     *
376     * If the given file name does not contain a '.' char, the empty String <code>""</code> is returned.<p>
377     *
378     * Please note: No check is performed to ensure the given file name is not <code>null</code>.<p>
379     *
380     * @param filename the file name to get the extension for
381     * @return the extension of the given file name
382     *
383     * @deprecated use {@link #getExtension(String)} instead, it is better implemented
384     */
385    @Deprecated
386    public static String getFileExtension(String filename) {
387
388        int pos = filename.lastIndexOf('.');
389        return (pos >= 0) ? filename.substring(pos).toLowerCase() : "";
390    }
391
392    /**
393     * Returns a list of all filtered files in the RFS.<p>
394     *
395     * If the <code>name</code> is not a folder the folder that contains the
396     * given file will be used instead.<p>
397     *
398     * Despite the filter may not accept folders, every subfolder is traversed
399     * if the <code>includeSubtree</code> parameter is set.<p>
400     *
401     * @param name a folder or file name
402     * @param filter a filter
403     * @param includeSubtree if to include subfolders
404     *
405     * @return a list of filtered <code>{@link File}</code> objects
406     */
407    public static List<File> getFiles(String name, FileFilter filter, boolean includeSubtree) {
408
409        List<File> ret = new ArrayList<File>();
410
411        File file = new File(name);
412        if (!file.isDirectory()) {
413            file = new File(file.getParent());
414            if (!file.isDirectory()) {
415                return ret;
416            }
417        }
418        File[] dirContent = file.listFiles();
419        for (int i = 0; i < dirContent.length; i++) {
420            File f = dirContent[i];
421            if (filter.accept(f)) {
422                ret.add(f);
423            }
424            if (includeSubtree && f.isDirectory()) {
425                ret.addAll(getFiles(f.getAbsolutePath(), filter, true));
426            }
427        }
428
429        return ret;
430    }
431
432    /**
433     * Returns the file name for a given VFS name that has to be written to a repository in the "real" file system,
434     * by appending the VFS root path to the given base repository path, also adding an
435     * folder for the "online" or "offline" project.<p>
436     *
437     * @param repository the base repository path
438     * @param vfspath the VFS root path to write to use
439     * @param online flag indicates if the result should be used for the online project (<code>true</code>) or not
440     *
441     * @return The full uri to the JSP
442     */
443    public static String getRepositoryName(String repository, String vfspath, boolean online) {
444
445        StringBuffer result = new StringBuffer(64);
446        result.append(repository);
447        result.append(online ? CmsFlexCache.REPOSITORY_ONLINE : CmsFlexCache.REPOSITORY_OFFLINE);
448        result.append(vfspath);
449        return result.toString();
450    }
451
452    /**
453     * Creates unique, valid RFS name for the given filename that contains
454     * a coded version of the given parameters, with the given file extension appended.<p>
455     *
456     * This is used to create file names for the static export,
457     * or in a vfs disk cache.<p>
458     *
459     * @param filename the base file name
460     * @param extension the extension to use
461     * @param parameters the parameters to code in the result file name
462     *
463     * @return a unique, valid RFS name for the given parameters
464     *
465     * @see org.opencms.staticexport.CmsStaticExportManager
466     */
467    public static String getRfsPath(String filename, String extension, String parameters) {
468
469        StringBuffer buf = new StringBuffer(128);
470        buf.append(filename);
471        buf.append('_');
472        int h = parameters.hashCode();
473        // ensure we do have a positive id value
474        buf.append(h > 0 ? h : -h);
475        buf.append(extension);
476        return buf.toString();
477    }
478
479    /**
480     * Normalizes a file path that might contain <code>'../'</code> or <code>'./'</code> or <code>'//'</code>
481     * elements to a normal absolute path, the path separator char used is {@link File#separatorChar}.<p>
482     *
483     * @param path the path to normalize
484     *
485     * @return the normalized path
486     *
487     * @see #normalizePath(String, char)
488     */
489    public static String normalizePath(String path) {
490
491        return normalizePath(path, File.separatorChar);
492    }
493
494    /**
495     * Normalizes a file path that might contain <code>'../'</code> or <code>'./'</code> or <code>'//'</code>
496     * elements to a normal absolute path.<p>
497     *
498     * Can also handle Windows like path information containing a drive letter,
499     * like <code>C:\path\..\</code>.<p>
500     *
501     * @param path the path to normalize
502     * @param separatorChar the file separator char to use, for example {@link File#separatorChar}
503     *
504     * @return the normalized path
505     */
506    public static String normalizePath(String path, char separatorChar) {
507
508        if (CmsStringUtil.isNotEmpty(path)) {
509
510            // handle windows paths including drive-information first
511            String drive = null;
512            if ((path.length() > 1) && (path.charAt(1) == ':')) {
513                // windows path like C:\home\
514                drive = path.substring(0, 2);
515                path = path.substring(2);
516            } else if ((path.length() > 1) && (path.charAt(0) == '\\') && (path.charAt(1) == '\\')) {
517                // windows path like \\home\ (network mapped drives)
518                drive = path.substring(0, 2);
519                path = path.substring(2);
520            }
521            // ensure all File separators are '/'
522            path = path.replace('\\', '/');
523            if (drive != null) {
524                drive = drive.replace('\\', '/');
525            }
526            if (path.charAt(0) == '/') {
527                // trick to resolve all ../ inside a path
528                path = '.' + path;
529            }
530            // resolve all '../' or './' elements in the path
531            path = CmsLinkManager.getAbsoluteUri(path, "/");
532            // still some '//' elements might persist
533            path = CmsStringUtil.substitute(path, "//", "/");
534            // re-append drive if required
535            if (drive != null) {
536                path = drive.concat(path);
537            }
538            // switch '/' back to OS dependend File separator if required
539            if (separatorChar != '/') {
540                path = path.replace('/', separatorChar);
541            }
542        }
543        return path;
544    }
545
546    /**
547     * Returns the normalized file path created from the given URL.<p>
548     *
549     * The path part {@link URL#getPath()} is used, unescaped and
550     * normalized using {@link #normalizePath(String, char)} using {@link File#separatorChar}.<p>
551     *
552     * @param url the URL to extract the path information from
553     *
554     * @return the normalized file path created from the given URL using {@link File#separatorChar}
555     *
556     * @see #normalizePath(URL, char)
557     */
558    public static String normalizePath(URL url) {
559
560        return normalizePath(url, File.separatorChar);
561    }
562
563    /**
564     * Returns the normalized file path created from the given URL.<p>
565     *
566     * The path part {@link URL#getPath()} is used, unescaped and
567     * normalized using {@link #normalizePath(String, char)}.<p>
568     *
569     * @param url the URL to extract the path information from
570     * @param separatorChar the file separator char to use, for example {@link File#separatorChar}
571     *
572     * @return the normalized file path created from the given URL
573     */
574    public static String normalizePath(URL url, char separatorChar) {
575
576        // get the path part from the URL
577        String path = new File(url.getPath()).getAbsolutePath();
578        // trick to get the OS default encoding, taken from the official Java i18n FAQ
579        String systemEncoding = (new OutputStreamWriter(new ByteArrayOutputStream())).getEncoding();
580        // decode url in order to remove spaces and escaped chars from path
581        return CmsFileUtil.normalizePath(CmsEncoder.decode(path, systemEncoding), separatorChar);
582    }
583
584    /**
585     * Deletes a directory in the file system and all subfolders of that directory.<p>
586     *
587     * @param directory the directory to delete
588     */
589    public static void purgeDirectory(File directory) {
590
591        if (directory.canRead() && directory.isDirectory()) {
592            File[] files = directory.listFiles();
593            for (int i = 0; i < files.length; i++) {
594                File f = files[i];
595                if (f.isDirectory()) {
596                    purgeDirectory(f);
597                }
598                if (f.canWrite()) {
599                    f.delete();
600                }
601            }
602            directory.delete();
603        }
604    }
605
606    /**
607     * Reads a file from the RFS and returns the file content.<p>
608     *
609     * @param file the file to read
610     * @return the read file content
611     *
612     * @throws IOException in case of file access errors
613     */
614    @SuppressWarnings("resource")
615    public static byte[] readFile(File file) throws IOException {
616
617        // create input and output stream
618        FileInputStream in = new FileInputStream(file);
619
620        // read the content
621        return readFully(in, (int)file.length());
622    }
623
624    /**
625     * Reads a file with the given name from the class loader and returns the file content.<p>
626     *
627     * @param filename the file to read
628     * @return the read file content
629     *
630     * @throws IOException in case of file access errors
631     */
632    @SuppressWarnings("resource")
633    public static byte[] readFile(String filename) throws IOException {
634
635        // create input and output stream
636        InputStream in = CmsFileUtil.class.getClassLoader().getResourceAsStream(filename);
637        if (in == null) {
638            throw new FileNotFoundException(filename);
639        }
640
641        return readFully(in);
642    }
643
644    /**
645     * Reads a file from the class loader and converts it to a String with the specified encoding.<p>
646     *
647     * @param filename the file to read
648     * @param encoding the encoding to use when converting the file content to a String
649     * @return the read file convered to a String
650     * @throws IOException in case of file access errors
651     */
652    public static String readFile(String filename, String encoding) throws IOException {
653
654        return new String(readFile(filename), encoding);
655    }
656
657    /**
658     * Reads all bytes from the given input stream, closes it
659     * and returns the result in an array.<p>
660     *
661     * @param in the input stream to read the bytes from
662     * @return the byte content of the input stream
663     *
664     * @throws IOException in case of errors in the underlying java.io methods used
665     */
666    public static byte[] readFully(InputStream in) throws IOException {
667
668        return readFully(in, true);
669    }
670
671    /**
672     * Reads all bytes from the given input stream, conditionally closes the given input stream
673     * and returns the result in an array.<p>
674     *
675     * @param in the input stream to read the bytes from
676     * @return the byte content of the input stream
677     * @param closeInputStream if true the given stream will be closed afterwards
678     *
679     * @throws IOException in case of errors in the underlying java.io methods used
680     */
681    public static byte[] readFully(InputStream in, boolean closeInputStream) throws IOException {
682
683        if (in instanceof ByteArrayInputStream) {
684            // content can be read in one pass
685            return readFully(in, in.available(), closeInputStream);
686        }
687
688        // copy buffer
689        byte[] xfer = new byte[2048];
690        // output buffer
691        ByteArrayOutputStream out = new ByteArrayOutputStream(xfer.length);
692
693        // transfer data from input to output in xfer-sized chunks.
694        for (int bytesRead = in.read(xfer, 0, xfer.length); bytesRead >= 0; bytesRead = in.read(xfer, 0, xfer.length)) {
695            if (bytesRead > 0) {
696                out.write(xfer, 0, bytesRead);
697            }
698        }
699        if (closeInputStream) {
700            in.close();
701        }
702        out.close();
703        return out.toByteArray();
704    }
705
706    /**
707     * Reads the specified number of bytes from the given input stream and returns the result in an array.<p>
708     *
709     * @param in the input stream to read the bytes from
710     * @param size the number of bytes to read
711     *
712     * @return the byte content read from the input stream
713     *
714     * @throws IOException in case of errors in the underlying java.io methods used
715     */
716    public static byte[] readFully(InputStream in, int size) throws IOException {
717
718        return readFully(in, size, true);
719    }
720
721    /**
722     * Reads the specified number of bytes from the given input stream, conditionally closes the stream
723     * and returns the result in an array.<p>
724     *
725     * @param in the input stream to read the bytes from
726     * @param size the number of bytes to read
727     * @param closeStream if true the given stream will be closed
728     *
729     * @return the byte content read from the input stream
730     *
731     * @throws IOException in case of errors in the underlying java.io methods used
732     */
733    public static byte[] readFully(InputStream in, int size, boolean closeStream) throws IOException {
734
735        // create the byte array to hold the data
736        byte[] bytes = new byte[size];
737
738        // read in the bytes
739        int offset = 0;
740
741        try {
742            int numRead = 0;
743            while (offset < size) {
744                numRead = in.read(bytes, offset, size - offset);
745                if (numRead >= 0) {
746                    offset += numRead;
747                } else {
748                    break;
749                }
750            }
751        } finally {
752            // close the input stream
753            if (closeStream) {
754                in.close();
755            }
756        }
757
758        // ensure all the bytes have been read in
759        if (offset < bytes.length) {
760            throw new IOException("Could not read requested " + size + " bytes from input stream");
761        }
762
763        return bytes;
764    }
765
766    /**
767     * Removes a leading separator from a path if required.<p>
768     *
769     * @param path the path to remove the leading separator from
770     * @return the path without a trailing separator
771     */
772    public static String removeLeadingSeparator(String path) {
773
774        int l = path.length();
775        if (l == 0) {
776            return "";
777        } else if (path.charAt(0) != '/') {
778            return path;
779        } else if (l == 1) {
780            return "";
781        } else {
782            return path.substring(1, l);
783        }
784    }
785
786    /**
787     * Removes all resource names in the given List that are "redundant" because the parent folder name
788     * is also contained in the List.<p>
789     *
790     * The content of the input list is not modified.<p>
791     *
792     * @param resourcenames a list of VFS pathnames to check for redundencies (Strings)
793     *
794     * @return a new list with all redundancies removed
795     *
796     * @see #removeRedundantResources(List)
797     */
798    public static List<String> removeRedundancies(List<String> resourcenames) {
799
800        if ((resourcenames == null) || (resourcenames.isEmpty())) {
801            return new ArrayList<String>();
802        }
803        if (resourcenames.size() == 1) {
804            // if there is only one resource name in the list, there can be no redundancies
805            return new ArrayList<String>(resourcenames);
806        }
807        // check all resources names and see if a parent folder name is contained
808        List<String> result = new ArrayList<String>(resourcenames.size());
809        List<String> base = new ArrayList<String>(resourcenames);
810        Collections.sort(base);
811        Iterator<String> i = base.iterator();
812        while (i.hasNext()) {
813            // check all resource names in the list
814            String resourcename = i.next();
815            if (CmsStringUtil.isEmptyOrWhitespaceOnly(resourcename)) {
816                // skip empty strings
817                continue;
818            }
819            boolean valid = true;
820            for (int j = (result.size() - 1); j >= 0; j--) {
821                // check if this resource name is indirectly contained because a parent folder name is contained
822                String check = result.get(j);
823                if ((CmsResource.isFolder(check) && resourcename.startsWith(check)) || resourcename.equals(check)) {
824                    valid = false;
825                    break;
826                }
827            }
828            if (valid) {
829                // a parent folder name is not already contained in the result
830                result.add(resourcename);
831            }
832        }
833        return result;
834    }
835
836    /**
837     * Removes all resources in the given List that are "redundant" because the parent folder
838     * is also contained in the List.<p>
839     *
840     * The content of the input list is not modified.<p>
841     *
842     * @param resources a list of <code>{@link CmsResource}</code> objects to check for redundancies
843     *
844     * @return a the given list with all redundancies removed
845     *
846     * @see #removeRedundancies(List)
847     */
848    public static List<CmsResource> removeRedundantResources(List<CmsResource> resources) {
849
850        if ((resources == null) || (resources.isEmpty())) {
851            return new ArrayList<CmsResource>();
852        }
853        if (resources.size() == 1) {
854            // if there is only one resource in the list, there can be no redundancies
855            return new ArrayList<CmsResource>(resources);
856        }
857        // check all resources and see if a parent folder name is contained
858        List<CmsResource> result = new ArrayList<CmsResource>(resources.size());
859        List<CmsResource> base = new ArrayList<CmsResource>(resources);
860        Collections.sort(base);
861        Iterator<CmsResource> i = base.iterator();
862        while (i.hasNext()) {
863            // check all folders in the list
864            CmsResource resource = i.next();
865            boolean valid = true;
866            for (int j = (result.size() - 1); j >= 0; j--) {
867                // check if this resource is indirectly contained because a parent folder is contained
868                CmsResource check = result.get(j);
869                if ((check.isFolder() && resource.getRootPath().startsWith(check.getRootPath()))
870                    || resource.getRootPath().equals(check.getRootPath())) {
871                    valid = false;
872                    break;
873                }
874            }
875            if (valid) {
876                // the parent folder is not already contained in the result
877                result.add(resource);
878            }
879        }
880        return result;
881    }
882
883    /**
884     * Removes a trailing separator from a path if required.<p>
885     *
886     * In case we have the root folder "/", the separator is not removed.<p>
887     *
888     * @param path the path to remove the trailing separator from
889     * @return the path without a trailing separator
890     */
891    public static String removeTrailingSeparator(String path) {
892
893        int l = path.length();
894        if ((l <= 1) || (path.charAt(l - 1) != '/')) {
895            return path;
896        } else {
897            return path.substring(0, l - 1);
898        }
899    }
900
901    /**
902     * Searches for the OpenCms web application 'WEB-INF' folder during system startup, code or
903     * <code>null</code> if the 'WEB-INF' folder can not be found.<p>
904     *
905     * @param startFolder the folder where to start searching
906     *
907     * @return String the path of the 'WEB-INF' folder in the 'real' file system, or <code>null</code>
908     */
909    public static String searchWebInfFolder(String startFolder) {
910
911        if (CmsStringUtil.isEmpty(startFolder)) {
912            return null;
913        }
914
915        File f = new File(startFolder);
916        if (!f.exists() || !f.isDirectory()) {
917            return null;
918        }
919
920        File configFile = new File(f, CmsSystemInfo.FILE_TLD);
921        if (configFile.exists() && configFile.isFile()) {
922            return f.getAbsolutePath();
923        }
924
925        String webInfFolder = null;
926        File[] subFiles = f.listFiles();
927        List<File> fileList = new ArrayList<File>(Arrays.asList(subFiles));
928        Collections.sort(fileList, new Comparator<File>() {
929
930            public int compare(File arg0, File arg1) {
931
932                // make sure that the WEB-INF folder, if it has that name, comes earlier
933                boolean a = arg0.getPath().contains("WEB-INF");
934                boolean b = arg1.getPath().contains("WEB-INF");
935                return Boolean.valueOf(b).compareTo(Boolean.valueOf(a));
936
937            }
938        });
939
940        for (File file : fileList) {
941            if (file.isDirectory()) {
942                webInfFolder = searchWebInfFolder(file.getAbsolutePath());
943                if (webInfFolder != null) {
944                    break;
945                }
946            }
947        }
948
949        return webInfFolder;
950    }
951
952    public static String toggleTrailingSeparator(String path) {
953
954        if (path.endsWith("/")) {
955            return path.substring(0, path.length() - 1);
956        } else {
957            return path + "/";
958        }
959    }
960
961    /**
962     * Traverses the file system starting from a base folder and executes a callback for every directory found.<p>
963     *
964     * @param base the base folder
965     * @param action a callback which will be passed a FileWalkState object for every directory encountered
966     */
967    public static void walkFileSystem(File base, Closure action) {
968
969        List<FileWalkState> m_states = new ArrayList<FileWalkState>();
970        m_states.add(createFileWalkState(base));
971        while (!m_states.isEmpty()) {
972            // pop the top off the state stack, process it, then push states for all subdirectories onto it
973            FileWalkState last = m_states.remove(m_states.size() - 1);
974            action.execute(last);
975            for (File dir : last.getDirectories()) {
976                m_states.add(createFileWalkState(dir));
977            }
978        }
979    }
980
981    /**
982     * Helper method for creating a FileWalkState object from a File object.<p>
983     *
984     * @param file the file
985     *
986     * @return the file walk state
987     */
988    private static FileWalkState createFileWalkState(File file) {
989
990        File[] contents = file.listFiles();
991        List<File> dirs = new ArrayList<File>();
992        List<File> files = new ArrayList<File>();
993        for (File subFile : contents) {
994            if (subFile.isDirectory()) {
995                dirs.add(subFile);
996            } else {
997                files.add(subFile);
998            }
999        }
1000        return new FileWalkState(file, dirs, files);
1001    }
1002}