001/*
002 * Syncany, www.syncany.org
003 * Copyright (C) 2011-2016 Philipp C. Heckel <philipp.heckel@gmail.com> 
004 *
005 * This program is free software: you can redistribute it and/or modify
006 * it under the terms of the GNU General Public License as published by
007 * the Free Software Foundation, either version 3 of the License, or
008 * (at your option) any later version.
009 *
010 * This program is distributed in the hope that it will be useful,
011 * but WITHOUT ANY WARRANTY; without even the implied warranty of
012 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
013 * GNU General Public License for more details.
014 *
015 * You should have received a copy of the GNU General Public License
016 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
017 */
018package org.syncany.util;
019
020import java.io.File;
021import java.nio.file.Files;
022import java.nio.file.InvalidPathException;
023import java.nio.file.Path;
024import java.nio.file.Paths;
025import java.util.ArrayList;
026import java.util.Arrays;
027import java.util.List;
028import java.util.logging.Level;
029import java.util.logging.Logger;
030import java.util.regex.Pattern;
031
032import org.syncany.util.EnvironmentUtil.OperatingSystem;
033
034public class NormalizedPath {
035        protected static final Logger logger = Logger.getLogger(NormalizedPath.class.getSimpleName());  
036        private static final Pattern ILLEGAL_CHARS_PATTERN_WINDOWS = Pattern.compile("[\\\\/:*?\"<>|\0]");
037        private static final Pattern ILLEGAL_CHARS_PATTERN_UNIX_LIKE = Pattern.compile("[\0]");
038        private static final Pattern ILLEGAL_NON_ASCII_CHARS_PATTERN = Pattern.compile("[^a-zA-Z0-9., ]");
039        
040        protected File root;
041        protected String normalizedPath;
042        
043        public NormalizedPath(File root, String normalizedPath) {
044                this.root = root;
045                this.normalizedPath = normalizedPath;
046        }
047        
048        @Override
049        public String toString() {
050                return normalizedPath;
051        }
052        
053        public NormalizedPath getParent() {
054                int lastIndexOfSlash = normalizedPath.lastIndexOf("/");
055                
056                if (lastIndexOfSlash == -1) {
057                        return new NormalizedPath(root, "");
058                }
059                else {
060                        return new NormalizedPath(root, normalizedPath.substring(0, lastIndexOfSlash));
061                }
062        }       
063
064        private List<String> getParts() {
065                return Arrays.asList(normalizedPath.split("[/]"));
066        }               
067        
068        public File toFile() {
069                if (root != null) {
070                        return new File(root, normalizedPath);
071                }
072                else {
073                        return new File(normalizedPath);
074                }
075        }
076        
077        private boolean canCreate(String pathPart) {
078                try {
079                        Path tempFile = Files.createTempFile(pathPart, "canCreate");
080                        Files.deleteIfExists(tempFile);
081
082                        return true;
083                }
084                catch (Exception e) {
085                        logger.log(Level.SEVERE, "WARNING: Cannot create file: "+pathPart);
086                        return false;
087                }
088        }
089        
090        public boolean hasIllegalChars() {
091                return hasIllegalChars(normalizedPath);
092        }
093        
094        private String getExtension(boolean includeDot) {
095                return getExtension(normalizedPath, includeDot);
096        }
097        
098        private String getExtension(String filename, boolean includeDot) {
099                int lastDot = filename.lastIndexOf(".");
100                int lastSlash = filename.lastIndexOf("/");
101
102                if (lastDot == -1 || lastSlash > lastDot) {
103                        return "";
104                }
105
106                String extension = filename.substring(lastDot + 1, filename.length());
107                return (includeDot) ? "." + extension : extension;
108        }
109        
110        private String getPathWithoutExtension(String filename) {
111                String extension = getExtension(true); // .txt
112                
113                if ("".equals(extension)) {
114                        return filename;
115                }
116                else {
117                        return filename.substring(0, filename.length() - extension.length());
118                }
119        }
120        
121        private boolean hasIllegalChars(String pathPart) {
122                if (EnvironmentUtil.isWindows() && ILLEGAL_CHARS_PATTERN_WINDOWS.matcher(pathPart).find()) {
123                        return true;
124                }               
125                else if (EnvironmentUtil.isUnixLikeOperatingSystem() && ILLEGAL_CHARS_PATTERN_UNIX_LIKE.matcher(pathPart).find()) {
126                        return true;
127                }
128                else {
129                        return false;
130                }
131        }
132        
133        private String cleanIllegalChars(String pathPart) {
134                if (EnvironmentUtil.isWindows()) {
135                        return ILLEGAL_CHARS_PATTERN_WINDOWS.matcher(pathPart).replaceAll("");                                          
136                }
137                else {
138                        return ILLEGAL_CHARS_PATTERN_UNIX_LIKE.matcher(pathPart).replaceAll("");
139                }
140        }       
141        
142        private String cleanAsciiOnly(String pathPart) {
143                return ILLEGAL_NON_ASCII_CHARS_PATTERN.matcher(pathPart).replaceAll("");
144        }       
145        
146        private String addFilenameConflictSuffix(String pathPart, String filenameSuffix) {
147                String conflictFileExtension = getExtension(pathPart, false);           
148                boolean originalFileHasExtension = conflictFileExtension != null && !"".equals(conflictFileExtension);
149
150                if (originalFileHasExtension) {
151                        String conflictFileBasename = getPathWithoutExtension(pathPart);
152                        return String.format("%s (%s).%s", conflictFileBasename, filenameSuffix, conflictFileExtension);                                                
153                }
154                else {
155                        return String.format("%s (%s)", pathPart, filenameSuffix);
156                }
157        }
158
159        public NormalizedPath withSuffix(String filenameSuffix, boolean canExist) throws Exception {
160                if (canExist) {
161                        return toCreatable(filenameSuffix, 0);
162                }
163                else {
164                        NormalizedPath creatableNormalizedPath = null;
165                        int attempt = 0;
166                        
167                        do {
168                                String aFilenameSuffix = (attempt > 0) ? filenameSuffix + " " + attempt : filenameSuffix;
169                                creatableNormalizedPath = new NormalizedPath(root, addFilenameConflictSuffix(normalizedPath.toString(), aFilenameSuffix));
170                                boolean exists = FileUtil.exists(creatableNormalizedPath.toFile());
171                                
172                                if (!exists) {
173                                        return creatableNormalizedPath;
174                                }
175                        } while (attempt++ < 200);
176                        
177                        throw new Exception("Cannot create path with suffix; "+attempt+" attempts: "+creatableNormalizedPath);
178                }
179        }
180        
181        /*     pictures/
182         *       some/
183         *         folder/
184         *           file.jpg
185         *       some\\folder/
186         * ->       file.jpg
187         * 
188         *  relativeNormalizedPath = pictures/some\\folder/file.jpg
189         *  
190         *  -> createable: pictures/somefolder (filename conflict)/file.jpg
191         *  
192         *  http://msdn.microsoft.com/en-us/library/system.io.path.getinvalidfilenamechars.aspx
193         */     
194        public NormalizedPath toCreatable(String filenameSuffix, boolean canExist) throws Exception {           
195                if (canExist) {
196                        return toCreatable(filenameSuffix, 0);
197                }
198                else {
199                        NormalizedPath creatableNormalizedPath = null;
200                        int attempt = 0;
201                        
202                        do {
203                                creatableNormalizedPath = toCreatable(filenameSuffix, attempt);
204                                boolean exists = FileUtil.exists(creatableNormalizedPath.toFile());
205                                
206                                // TODO [medium] The exists-check should be in the pathPart-loop, b/c what if fileB is a FILE in this path: folderA/fileB/folderC/file1.jpg
207                                
208                                if (!exists) {
209                                        return creatableNormalizedPath;
210                                }
211                                
212                                logger.log(Level.WARNING, " - File exists, trying new file: " + creatableNormalizedPath.toFile());
213                        } while (attempt++ < 10);
214                        
215                        throw new Exception("Cannot create creatable path; "+creatableNormalizedPath+" attempts: "+attempt);
216                }
217        }
218        
219        private NormalizedPath toCreatable(String filenameSuffix, int attempt) {                
220                List<String> cleanedRelativePathParts = new ArrayList<String>();
221                String attemptedFilenameSuffix = (attempt > 0) ? filenameSuffix + " " + attempt : filenameSuffix;
222                
223                for (String pathPart : getParts()) {
224                        boolean needsCleansing = false;
225                        
226                        // Determine if path part is illegal
227                        if (hasIllegalChars(pathPart)) {
228                                needsCleansing = true;
229                        }
230                        else {
231                                try {
232                                        Paths.get(pathPart);
233                                }
234                                catch (InvalidPathException e) {
235                                        needsCleansing = true;
236                                }
237                        }
238                        
239                        // Clean if it is illegal
240                        if (needsCleansing) {                           
241                                String cleanedParentPart = addFilenameConflictSuffix(cleanIllegalChars(pathPart), attemptedFilenameSuffix); // TODO [low] attempt does not make sense hree
242                                
243                                // Check if cleaned path actually can be created (creates local file!)
244                                if (canCreate(cleanedParentPart)) {
245                                        pathPart = cleanedParentPart;
246                                }
247                                else {
248                                        pathPart = addFilenameConflictSuffix(cleanAsciiOnly(pathPart), attemptedFilenameSuffix); // TODO [low] attempt does not make sense hree
249                                }                               
250                                
251                                logger.log(Level.INFO, "       + WAS ILLEGAL: Now: "+pathPart);
252                        }
253                        
254                        // Add to path part list
255                        cleanedRelativePathParts.add(pathPart);
256                }
257                
258                String cleanedRelativeTargetPath = StringUtil.join(cleanedRelativePathParts, File.separator);
259                return new NormalizedPath(root, cleanedRelativeTargetPath);             
260        }
261}