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