NormalizedPath.java

/*
 * Syncany, www.syncany.org
 * Copyright (C) 2011-2016 Philipp C. Heckel <philipp.heckel@gmail.com> 
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */
package org.syncany.util;

import java.io.File;
import java.nio.file.Files;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.regex.Pattern;

import org.syncany.util.EnvironmentUtil.OperatingSystem;

public class NormalizedPath {
	protected static final Logger logger = Logger.getLogger(NormalizedPath.class.getSimpleName());	
	private static final Pattern ILLEGAL_CHARS_PATTERN_WINDOWS = Pattern.compile("[\\\\/:*?\"<>|\0]");
	private static final Pattern ILLEGAL_CHARS_PATTERN_UNIX_LIKE = Pattern.compile("[\0]");
	private static final Pattern ILLEGAL_NON_ASCII_CHARS_PATTERN = Pattern.compile("[^a-zA-Z0-9., ]");
	
	protected File root;
	protected String normalizedPath;
	
	public NormalizedPath(File root, String normalizedPath) {
		this.root = root;
		this.normalizedPath = normalizedPath;
	}
	
	@Override
	public String toString() {
		return normalizedPath;
	}
	
	public NormalizedPath getParent() {
		int lastIndexOfSlash = normalizedPath.lastIndexOf("/");
		
		if (lastIndexOfSlash == -1) {
			return new NormalizedPath(root, "");
		}
		else {
			return new NormalizedPath(root, normalizedPath.substring(0, lastIndexOfSlash));
		}
	}	

	private List<String> getParts() {
		return Arrays.asList(normalizedPath.split("[/]"));
	}		
	
	public File toFile() {
		if (root != null) {
			return new File(root, normalizedPath);
		}
		else {
			return new File(normalizedPath);
		}
	}
	
	private boolean canCreate(String pathPart) {
		try {
			Path tempFile = Files.createTempFile(pathPart, "canCreate");
			Files.deleteIfExists(tempFile);

			return true;
		}
		catch (Exception e) {
			logger.log(Level.SEVERE, "WARNING: Cannot create file: "+pathPart);
			return false;
		}
	}
	
	public boolean hasIllegalChars() {
		return hasIllegalChars(normalizedPath);
	}
	
	private String getExtension(boolean includeDot) {
		return getExtension(normalizedPath, includeDot);
	}
	
	private String getExtension(String filename, boolean includeDot) {
		int lastDot = filename.lastIndexOf(".");
		int lastSlash = filename.lastIndexOf("/");

		if (lastDot == -1 || lastSlash > lastDot) {
			return "";
		}

		String extension = filename.substring(lastDot + 1, filename.length());
		return (includeDot) ? "." + extension : extension;
	}
	
	private String getPathWithoutExtension(String filename) {
		String extension = getExtension(true); // .txt
		
		if ("".equals(extension)) {
			return filename;
		}
		else {
			return filename.substring(0, filename.length() - extension.length());
		}
	}
	
	private boolean hasIllegalChars(String pathPart) {
		if (EnvironmentUtil.isWindows() && ILLEGAL_CHARS_PATTERN_WINDOWS.matcher(pathPart).find()) {
			return true;
		}		
		else if (EnvironmentUtil.isUnixLikeOperatingSystem() && ILLEGAL_CHARS_PATTERN_UNIX_LIKE.matcher(pathPart).find()) {
			return true;
		}
		else {
			return false;
		}
	}
	
	private String cleanIllegalChars(String pathPart) {
		if (EnvironmentUtil.isWindows()) {
			return ILLEGAL_CHARS_PATTERN_WINDOWS.matcher(pathPart).replaceAll("");						
		}
		else {
			return ILLEGAL_CHARS_PATTERN_UNIX_LIKE.matcher(pathPart).replaceAll("");
		}
	}	
	
	private String cleanAsciiOnly(String pathPart) {
		return ILLEGAL_NON_ASCII_CHARS_PATTERN.matcher(pathPart).replaceAll("");
	}	
	
	private String addFilenameConflictSuffix(String pathPart, String filenameSuffix) {
		String conflictFileExtension = getExtension(pathPart, false);		
		boolean originalFileHasExtension = conflictFileExtension != null && !"".equals(conflictFileExtension);

		if (originalFileHasExtension) {
			String conflictFileBasename = getPathWithoutExtension(pathPart);
			return String.format("%s (%s).%s", conflictFileBasename, filenameSuffix, conflictFileExtension);						
		}
		else {
			return String.format("%s (%s)", pathPart, filenameSuffix);
		}
	}

	public NormalizedPath withSuffix(String filenameSuffix, boolean canExist) throws Exception {
		if (canExist) {
			return toCreatable(filenameSuffix, 0);
		}
		else {
			NormalizedPath creatableNormalizedPath = null;
			int attempt = 0;
			
			do {
				String aFilenameSuffix = (attempt > 0) ? filenameSuffix + " " + attempt : filenameSuffix;
				creatableNormalizedPath = new NormalizedPath(root, addFilenameConflictSuffix(normalizedPath.toString(), aFilenameSuffix));
				boolean exists = FileUtil.exists(creatableNormalizedPath.toFile());
				
				if (!exists) {
					return creatableNormalizedPath;
				}
			} while (attempt++ < 200);
			
			throw new Exception("Cannot create path with suffix; "+attempt+" attempts: "+creatableNormalizedPath);
		}
	}
	
	/*     pictures/
	 *       some/
	 *         folder/
	 *           file.jpg
	 *       some\\folder/
	 * ->       file.jpg
	 * 
	 *  relativeNormalizedPath = pictures/some\\folder/file.jpg
	 *  
	 *  -> createable: pictures/somefolder (filename conflict)/file.jpg
	 *  
	 *  http://msdn.microsoft.com/en-us/library/system.io.path.getinvalidfilenamechars.aspx
	 */	
	public NormalizedPath toCreatable(String filenameSuffix, boolean canExist) throws Exception {		
		if (canExist) {
			return toCreatable(filenameSuffix, 0);
		}
		else {
			NormalizedPath creatableNormalizedPath = null;
			int attempt = 0;
			
			do {
				creatableNormalizedPath = toCreatable(filenameSuffix, attempt);
				boolean exists = FileUtil.exists(creatableNormalizedPath.toFile());
				
				// 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
				
				if (!exists) {
					return creatableNormalizedPath;
				}
				
				logger.log(Level.WARNING, " - File exists, trying new file: " + creatableNormalizedPath.toFile());
			} while (attempt++ < 10);
			
			throw new Exception("Cannot create creatable path; "+creatableNormalizedPath+" attempts: "+attempt);
		}
	}
	
	private NormalizedPath toCreatable(String filenameSuffix, int attempt) {		
		List<String> cleanedRelativePathParts = new ArrayList<String>();
		String attemptedFilenameSuffix = (attempt > 0) ? filenameSuffix + " " + attempt : filenameSuffix;
		
		for (String pathPart : getParts()) {
			boolean needsCleansing = false;
			
			// Determine if path part is illegal
			if (hasIllegalChars(pathPart)) {
				needsCleansing = true;
			}
			else {
				try {
					Paths.get(pathPart);
				}
				catch (InvalidPathException e) {
					needsCleansing = true;
				}
			}
			
			// Clean if it is illegal
			if (needsCleansing) {				
				String cleanedParentPart = addFilenameConflictSuffix(cleanIllegalChars(pathPart), attemptedFilenameSuffix); // TODO [low] attempt does not make sense hree
				
				// Check if cleaned path actually can be created (creates local file!)
				if (canCreate(cleanedParentPart)) {
					pathPart = cleanedParentPart;
				}
				else {
					pathPart = addFilenameConflictSuffix(cleanAsciiOnly(pathPart), attemptedFilenameSuffix); // TODO [low] attempt does not make sense hree
				}				
				
				logger.log(Level.INFO, "       + WAS ILLEGAL: Now: "+pathPart);
			}
			
			// Add to path part list
			cleanedRelativePathParts.add(pathPart);
		}
		
		String cleanedRelativeTargetPath = StringUtil.join(cleanedRelativePathParts, File.separator);
		return new NormalizedPath(root, cleanedRelativeTargetPath);		
	}
}