diff --git a/app/build.gradle b/app/build.gradle index 22bfe3eebe..17691e8abd 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -9,18 +9,22 @@ android { ndkVersion project.properties.ndkVersion dependencies { - implementation "androidx.annotation:annotation:1.1.0" - implementation 'androidx.appcompat:appcompat:1.2.0' - implementation 'androidx.core:core:1.5.0-beta03' + implementation "androidx.annotation:annotation:1.2.0" + implementation "androidx.core:core:1.5.0-rc01" implementation "androidx.drawerlayout:drawerlayout:1.1.1" - implementation 'androidx.preference:preference:1.1.1' + implementation "androidx.preference:preference:1.1.1" implementation "androidx.viewpager:viewpager:1.0.0" - implementation 'com.google.guava:guava:24.1-jre' - implementation "io.noties.markwon:core:$markwon_version" - implementation "io.noties.markwon:ext-strikethrough:$markwon_version" - implementation "io.noties.markwon:linkify:$markwon_version" - implementation "io.noties.markwon:recycler:$markwon_version" + implementation "com.google.guava:guava:24.1-jre" + implementation "io.noties.markwon:core:$markwon_version" + implementation "io.noties.markwon:ext-strikethrough:$markwon_version" + implementation "io.noties.markwon:linkify:$markwon_version" + implementation "io.noties.markwon:recycler:$markwon_version" implementation project(":terminal-view") + + // Do not increment version higher than 2.5 or there + // will be runtime exceptions on android < 8 + // due to missing classes like java.nio.file.Path. + implementation "commons-io:commons-io:2.5" } defaultConfig { @@ -95,10 +99,8 @@ android { } dependencies { - implementation 'androidx.appcompat:appcompat:1.2.0' - implementation 'androidx.constraintlayout:constraintlayout:2.0.4' - testImplementation 'junit:junit:4.13.1' - testImplementation 'org.robolectric:robolectric:4.4' + testImplementation "junit:junit:4.13.1" + testImplementation "org.robolectric:robolectric:4.4" } task versionName { diff --git a/app/src/main/java/com/termux/app/RunCommandService.java b/app/src/main/java/com/termux/app/RunCommandService.java index 5c2362588d..67339f6f1d 100644 --- a/app/src/main/java/com/termux/app/RunCommandService.java +++ b/app/src/main/java/com/termux/app/RunCommandService.java @@ -14,7 +14,7 @@ import com.termux.R; import com.termux.app.TermuxConstants.TERMUX_APP.RUN_COMMAND_SERVICE; import com.termux.app.TermuxConstants.TERMUX_APP.TERMUX_SERVICE; -import com.termux.app.utils.FileUtils; +import com.termux.app.file.FileUtils; import com.termux.app.utils.Logger; import com.termux.app.utils.NotificationUtils; import com.termux.app.utils.PluginUtils; @@ -354,9 +354,9 @@ public int onStartCommand(Intent intent, int flags, int startId) { // If executable is not a regular file, or is not readable or executable, then just return // Setting of missing read and execute permissions is not done - errmsg = FileUtils.validateRegularFileExistenceAndPermissions(this, executionCommand.executable, - null, PluginUtils.PLUGIN_EXECUTABLE_FILE_PERMISSIONS, - false, false); + errmsg = FileUtils.validateRegularFileExistenceAndPermissions(this, "executable", executionCommand.executable, null, + PluginUtils.PLUGIN_EXECUTABLE_FILE_PERMISSIONS, true, true, + false); if (errmsg != null) { errmsg += "\n" + this.getString(R.string.msg_executable_absolute_path, executionCommand.executable); executionCommand.setStateFailed(ExecutionCommand.RESULT_CODE_FAILED, errmsg, null); @@ -376,10 +376,9 @@ public int onStartCommand(Intent intent, int flags, int startId) { // under {@link TermuxConstants#TERMUX_FILES_DIR_PATH} // We try to set execute permissions, but ignore if they are missing, since only read and write permissions are required // for working directories. - errmsg = FileUtils.validateDirectoryExistenceAndPermissions(this, executionCommand.workingDirectory, - TermuxConstants.TERMUX_FILES_DIR_PATH, PluginUtils.PLUGIN_WORKING_DIRECTORY_PERMISSIONS, - true, true, false, - true); + errmsg = FileUtils.validateDirectoryFileExistenceAndPermissions(this, "working", executionCommand.workingDirectory, TermuxConstants.TERMUX_FILES_DIR_PATH, true, + PluginUtils.PLUGIN_WORKING_DIRECTORY_PERMISSIONS, true, true, + true, true); if (errmsg != null) { errmsg += "\n" + this.getString(R.string.msg_working_directory_absolute_path, executionCommand.workingDirectory); executionCommand.setStateFailed(ExecutionCommand.RESULT_CODE_FAILED, errmsg, null); diff --git a/app/src/main/java/com/termux/app/file/FileUtils.java b/app/src/main/java/com/termux/app/file/FileUtils.java new file mode 100644 index 0000000000..4e5abaddca --- /dev/null +++ b/app/src/main/java/com/termux/app/file/FileUtils.java @@ -0,0 +1,1627 @@ +package com.termux.app.file; + +import android.content.Context; +import android.os.Build; +import android.system.Os; + +import androidx.annotation.NonNull; + +import com.google.common.io.RecursiveDeleteOption; +import com.termux.R; +import com.termux.app.TermuxConstants; +import com.termux.app.file.filesystem.FileType; +import com.termux.app.file.filesystem.FileTypes; +import com.termux.app.utils.DataUtils; +import com.termux.app.utils.Logger; + +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.Closeable; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.nio.charset.Charset; +import java.nio.file.LinkOption; +import java.nio.file.StandardCopyOption; +import java.util.regex.Pattern; + +public class FileUtils { + + private static final String LOG_TAG = "FileUtils"; + + /** + * Replace "$PREFIX/" or "~/" prefix with termux absolute paths. + * + * @param path The {@code path} to expand. + * @return Returns the {@code expand path}. + */ + public static String getExpandedTermuxPath(String path) { + if(path != null && !path.isEmpty()) { + path = path.replaceAll("^\\$PREFIX$", TermuxConstants.TERMUX_PREFIX_DIR_PATH); + path = path.replaceAll("^\\$PREFIX/", TermuxConstants.TERMUX_PREFIX_DIR_PATH + "/"); + path = path.replaceAll("^~/$", TermuxConstants.TERMUX_HOME_DIR_PATH); + path = path.replaceAll("^~/", TermuxConstants.TERMUX_HOME_DIR_PATH + "/"); + } + + return path; + } + + /** + * Replace termux absolute paths with "$PREFIX/" or "~/" prefix. + * + * @param path The {@code path} to unexpand. + * @return Returns the {@code unexpand path}. + */ + public static String getUnExpandedTermuxPath(String path) { + if(path != null && !path.isEmpty()) { + path = path.replaceAll("^" + Pattern.quote(TermuxConstants.TERMUX_PREFIX_DIR_PATH) + "/", "\\$PREFIX/"); + path = path.replaceAll("^" + Pattern.quote(TermuxConstants.TERMUX_HOME_DIR_PATH) + "/", "~/"); + } + + return path; + } + + /** + * If {@code expandPath} is enabled, then input path is first attempted to be expanded by calling + * {@link #getExpandedTermuxPath(String)}. + * + * Then if path is already an absolute path, then it is used as is to get canonical path. + * If path is not an absolute path and {code prefixForNonAbsolutePath} is not {@code null}, then + * {code prefixForNonAbsolutePath} + "/" is prefixed before path before getting canonical path. + * If path is not an absolute path and {code prefixForNonAbsolutePath} is {@code null}, then + * "/" is prefixed before path before getting canonical path. + * + * If an exception is raised to get the canonical path, then absolute path is returned. + * + * @param path The {@code path} to convert. + * @param prefixForNonAbsolutePath Optional prefix path to prefix before non-absolute paths. This + * can be set to {@code null} if non-absolute paths should + * be prefixed with "/". The call to {@link File#getCanonicalPath()} + * will automatically do this anyways. + * @return Returns the {@code canonical path}. + */ + public static String getCanonicalPath(String path, final String prefixForNonAbsolutePath, final boolean expandPath) { + if (path == null) path = ""; + + if(expandPath) + path = getExpandedTermuxPath(path); + + String absolutePath; + + // If path is already an absolute path + if (path.startsWith("/")) { + absolutePath = path; + } else { + if (prefixForNonAbsolutePath != null) + absolutePath = prefixForNonAbsolutePath + "/" + path; + else + absolutePath = "/" + path; + } + + try { + return new File(absolutePath).getCanonicalPath(); + } catch(Exception e) { + } + + return absolutePath; + } + + /** + * Removes one or more forward slashes "//" with single slash "/" + * Removes "./" + * Removes trailing forward slash "/" + * + * @param path The {@code path} to convert. + * @return Returns the {@code normalized path}. + */ + public static String normalizePath(String path) { + if (path == null) return null; + + path = path.replaceAll("/+", "/"); + path = path.replaceAll("\\./", ""); + + if (path.endsWith("/")) { + path = path.substring(0, path.length() - 1); + } + + return path; + } + + /** + * Determines whether path is in {@code dirPath}. The {@code dirPath} is not canonicalized and + * only normalized. + * + * @param path The {@code path} to check. + * @param dirPath The {@code directory path} to check in. + * @param ensureUnder If set to {@code true}, then it will be ensured that {@code path} is + * under the directory and does not equal it. + * @return Returns {@code true} if path in {@code dirPath}, otherwise returns {@code false}. + */ + public static boolean isPathInDirPath(String path, final String dirPath, final boolean ensureUnder) { + if (path == null || dirPath == null) return false; + + try { + path = new File(path).getCanonicalPath(); + } catch(Exception e) { + return false; + } + + String normalizedDirPath = normalizePath(dirPath); + + if(ensureUnder) + return !path.equals(normalizedDirPath) && path.startsWith(normalizedDirPath + "/"); + else + return path.startsWith(normalizedDirPath + "/"); + } + + + + /** + * Checks whether a regular file exists at {@code filePath}. + * + * @param filePath The {@code path} for regular file to check. + * @param followLinks The {@code boolean} that decides if symlinks will be followed while + * finding if file exists. Check {@link #getFileType(String, boolean)} + * for details. + * @return Returns {@code true} if regular file exists, otherwise {@code false}. + */ + public static boolean regularFileExists(final String filePath, final boolean followLinks) { + return getFileType(filePath, followLinks) == FileType.REGULAR; + } + + /** + * Checks whether a directory file exists at {@code filePath}. + * + * @param filePath The {@code path} for directory file to check. + * @param followLinks The {@code boolean} that decides if symlinks will be followed while + * finding if file exists. Check {@link #getFileType(String, boolean)} + * for details. + * @return Returns {@code true} if directory file exists, otherwise {@code false}. + */ + public static boolean directoryFileExists(final String filePath, final boolean followLinks) { + return getFileType(filePath, followLinks) == FileType.DIRECTORY; + } + + /** + * Checks whether a symlink file exists at {@code filePath}. + * + * @param filePath The {@code path} for symlink file to check. + * @return Returns {@code true} if symlink file exists, otherwise {@code false}. + */ + public static boolean symlinkFileExists(final String filePath) { + return getFileType(filePath, false) == FileType.SYMLINK; + } + + /** + * Checks whether any file exists at {@code filePath}. + * + * @param filePath The {@code path} for file to check. + * @param followLinks The {@code boolean} that decides if symlinks will be followed while + * finding if file exists. Check {@link #getFileType(String, boolean)} + * for details. + * @return Returns {@code true} if file exists, otherwise {@code false}. + */ + public static boolean fileExists(final String filePath, final boolean followLinks) { + return getFileType(filePath, followLinks) != FileType.NO_EXIST; + } + + /** + * Checks the type of file that exists at {@code filePath}. + * + * This function is a wrapper for + * {@link FileTypes#getFileType(String, boolean)} + * + * @param filePath The {@code path} for file to check. + * @param followLinks The {@code boolean} that decides if symlinks will be followed while + * finding type. If set to {@code true}, then type of symlink target will + * be returned if file at {@code filePath} is a symlink. If set to + * {@code false}, then type of file at {@code filePath} itself will be + * returned. + * @return Returns the {@link FileType} of file. + */ + public static FileType getFileType(final String filePath, final boolean followLinks) { + return FileTypes.getFileType(filePath, followLinks); + } + + + + /** + * Validate the existence and permissions of regular file at path. + * + * If the {@code parentDirPath} is not {@code null}, then setting of missing permissions will + * only be done if {@code path} is under {@code parentDirPath}. + * + * @param context The {@link Context} to get error string. + * @param label The optional label for the regular file. This can optionally be {@code null}. + * @param filePath The {@code path} for file to validate. Symlinks will not be followed. + * @param parentDirPath The optional {@code parent directory path} to restrict operations to. + * This can optionally be {@code null}. It is not canonicalized and only normalized. + * @param permissionsToCheck The 3 character string that contains the "r", "w", "x" or "-" in-order. + * @param setPermissions The {@code boolean} that decides if permissions are to be + * automatically set defined by {@code permissionsToCheck}. + * @param setMissingPermissionsOnly The {@code boolean} that decides if only missing permissions + * are to be set or if they should be overridden. + * @param ignoreErrorsIfPathIsUnderParentDirPath The {@code boolean} that decides if permission + * errors are to be ignored if path is under + * {@code parentDirPath}. + * @return Returns the {@code errmsg} if path is not a regular file, or validating permissions + * failed, otherwise {@code null}. + */ + public static String validateRegularFileExistenceAndPermissions(@NonNull final Context context, String label, final String filePath, final String parentDirPath, + final String permissionsToCheck, final boolean setPermissions, final boolean setMissingPermissionsOnly, + final boolean ignoreErrorsIfPathIsUnderParentDirPath) { + label = (label == null ? "" : label + " "); + if (filePath == null || filePath.isEmpty()) return context.getString(R.string.error_null_or_empty_parameter, label + "regular file path", "validateRegularFileExistenceAndPermissions"); + + try { + FileType fileType = getFileType(filePath, false); + + // If file exists but not a regular file + if (fileType != FileType.NO_EXIST && fileType != FileType.REGULAR) { + return context.getString(R.string.error_non_regular_file_found, label + "file"); + } + + boolean isPathUnderParentDirPath = false; + if (parentDirPath != null) { + // The path can only be under parent directory path + isPathUnderParentDirPath = isPathInDirPath(filePath, parentDirPath, true); + } + + // If setPermissions is enabled and path is a regular file + if (setPermissions && permissionsToCheck != null && fileType == FileType.REGULAR) { + // If there is not parentDirPath restriction or path is under parentDirPath + if (parentDirPath == null || (isPathUnderParentDirPath && getFileType(parentDirPath, false) == FileType.DIRECTORY)) { + if(setMissingPermissionsOnly) + setMissingFilePermissions(label + "file", filePath, permissionsToCheck); + else + setFilePermissions(label + "file", filePath, permissionsToCheck); + } + } + + // If path is not a regular file + // Regular files cannot be automatically created so we do not ignore if missing + if (fileType != FileType.REGULAR) { + return context.getString(R.string.error_no_regular_file_found, label + "file"); + } + + // If there is not parentDirPath restriction or path is not under parentDirPath or + // if permission errors must not be ignored for paths under parentDirPath + if (parentDirPath == null || !isPathUnderParentDirPath || !ignoreErrorsIfPathIsUnderParentDirPath) { + if (permissionsToCheck != null) { + // Check if permissions are missing + return checkMissingFilePermissions(context, label + "regular", filePath, permissionsToCheck, false); + } + } + } catch (Exception e) { + return context.getString(R.string.error_validate_file_existence_and_permissions_failed_with_exception, label + "file", filePath, e.getMessage()); + } + + return null; + + } + + /** + * Validate the existence and permissions of directory file at path. + * + * If the {@code parentDirPath} is not {@code null}, then creation of missing directory and + * setting of missing permissions will only be done if {@code path} is under + * {@code parentDirPath} or equals {@code parentDirPath}. + * + * @param context The {@link Context} to get error string. + * @param label The optional label for the directory file. This can optionally be {@code null}. + * @param filePath The {@code path} for file to validate or create. Symlinks will not be followed. + * @param parentDirPath The optional {@code parent directory path} to restrict operations to. + * This can optionally be {@code null}. It is not canonicalized and only normalized. + * @param createDirectoryIfMissing The {@code boolean} that decides if directory file + * should be created if its missing. + * @param permissionsToCheck The 3 character string that contains the "r", "w", "x" or "-" in-order. + * @param setPermissions The {@code boolean} that decides if permissions are to be + * automatically set defined by {@code permissionsToCheck}. + * @param setMissingPermissionsOnly The {@code boolean} that decides if only missing permissions + * are to be set or if they should be overridden. + * @param ignoreErrorsIfPathIsInParentDirPath The {@code boolean} that decides if existence + * and permission errors are to be ignored if path is + * in {@code parentDirPath}. + * @param ignoreIfNotExecutable The {@code boolean} that decides if missing executable permission + * error is to be ignored. This allows making an attempt to set + * executable permissions, but ignoring if it fails. + * @return Returns the {@code errmsg} if path is not a directory file, failed to create it, + * or validating permissions failed, otherwise {@code null}. + */ + public static String validateDirectoryFileExistenceAndPermissions(@NonNull final Context context, String label, final String filePath, final String parentDirPath, final boolean createDirectoryIfMissing, + final String permissionsToCheck, final boolean setPermissions, final boolean setMissingPermissionsOnly, + final boolean ignoreErrorsIfPathIsInParentDirPath, final boolean ignoreIfNotExecutable) { + label = (label == null ? "" : label + " "); + if (filePath == null || filePath.isEmpty()) return context.getString(R.string.error_null_or_empty_parameter, label + "directory file path", "validateDirectoryExistenceAndPermissions"); + + try { + File file = new File(filePath); + FileType fileType = getFileType(filePath, false); + + // If file exists but not a directory file + if (fileType != FileType.NO_EXIST && fileType != FileType.DIRECTORY) { + return context.getString(R.string.error_non_directory_file_found, label + "directory"); + } + + boolean isPathInParentDirPath = false; + if (parentDirPath != null) { + // The path can be equal to parent directory path or under it + isPathInParentDirPath = isPathInDirPath(filePath, parentDirPath, false); + } + + if (createDirectoryIfMissing || setPermissions) { + // If there is not parentDirPath restriction or path is in parentDirPath + if (parentDirPath == null || (isPathInParentDirPath && getFileType(parentDirPath, false) == FileType.DIRECTORY)) { + // If createDirectoryIfMissing is enabled and no file exists at path, then create directory + if (createDirectoryIfMissing && fileType == FileType.NO_EXIST) { + Logger.logVerbose(LOG_TAG, "Creating " + label + "directory file at path \"" + filePath + "\""); + // Create directory and update fileType if successful, otherwise return with error + if (file.mkdirs()) + fileType = getFileType(filePath, false); + else + return context.getString(R.string.error_creating_file_failed, label + "directory file", filePath); + } + + // If setPermissions is enabled and path is a directory + if (setPermissions && permissionsToCheck != null && fileType == FileType.DIRECTORY) { + if(setMissingPermissionsOnly) + setMissingFilePermissions(label + "directory", filePath, permissionsToCheck); + else + setFilePermissions(label + "directory", filePath, permissionsToCheck); + } + } + } + + // If there is not parentDirPath restriction or path is not in parentDirPath or + // if existence or permission errors must not be ignored for paths in parentDirPath + if (parentDirPath == null || !isPathInParentDirPath || !ignoreErrorsIfPathIsInParentDirPath) { + // If path is not a directory + // Directories can be automatically created so we can ignore if missing with above check + if (fileType != FileType.DIRECTORY) { + return context.getString(R.string.error_file_not_found_at_path, label + "directory", filePath); + } + + if (permissionsToCheck != null) { + // Check if permissions are missing + return checkMissingFilePermissions(context, label + "directory", filePath, permissionsToCheck, ignoreIfNotExecutable); + } + } + } catch (Exception e) { + return context.getString(R.string.error_validate_directory_existence_and_permissions_failed_with_exception, label + "directory file", filePath, e.getMessage()); + } + + return null; + } + + + + /** + * Create a regular file at path. + * + * This function is a wrapper for + * {@link #validateDirectoryFileExistenceAndPermissions(Context, String, String, String, boolean, String, boolean, boolean, boolean, boolean)}. + * + * @param context The {@link Context} to get error string. + * @param filePath The {@code path} for regular file to create. + * @return Returns the {@code errmsg} if path is not a regular file or failed to create it, + * otherwise {@code null}. + */ + public static String createRegularFile(@NonNull final Context context, final String filePath) { + return createRegularFile(context, null, filePath); + } + + /** + * Create a regular file at path. + * + * This function is a wrapper for + * {@link #validateDirectoryFileExistenceAndPermissions(Context, String, String, String, boolean, String, boolean, boolean, boolean, boolean)}. + * + * @param context The {@link Context} to get error string. + * @param label The optional label for the regular file. This can optionally be {@code null}. + * @param filePath The {@code path} for regular file to create. + * @return Returns the {@code errmsg} if path is not a regular file or failed to create it, + * otherwise {@code null}. + */ + public static String createRegularFile(@NonNull final Context context, final String label, final String filePath) { + return createRegularFile(context, label, filePath, + null, false, false); + } + + /** + * Create a regular file at path. + * + * This function is a wrapper for + * {@link #validateRegularFileExistenceAndPermissions(Context, String, String, String, String, boolean, boolean, boolean)}. + * + * @param context The {@link Context} to get error string. + * @param label The optional label for the regular file. This can optionally be {@code null}. + * @param filePath The {@code path} for regular file to create. + * @param permissionsToCheck The 3 character string that contains the "r", "w", "x" or "-" in-order. + * @param setPermissions The {@code boolean} that decides if permissions are to be + * automatically set defined by {@code permissionsToCheck}. + * @param setMissingPermissionsOnly The {@code boolean} that decides if only missing permissions + * are to be set or if they should be overridden. + * @return Returns the {@code errmsg} if path is not a regular file, failed to create it, + * or validating permissions failed, otherwise {@code null}. + */ + public static String createRegularFile(@NonNull final Context context, String label, final String filePath, + final String permissionsToCheck, final boolean setPermissions, final boolean setMissingPermissionsOnly) { + label = (label == null ? "" : label + " "); + if (filePath == null || filePath.isEmpty()) return context.getString(R.string.error_null_or_empty_parameter, label + "file path", "createRegularFile"); + + String errmsg; + + File file = new File(filePath); + FileType fileType = getFileType(filePath, false); + + // If file exists but not a regular file + if (fileType != FileType.NO_EXIST && fileType != FileType.REGULAR) { + return context.getString(R.string.error_non_regular_file_found, label + "file"); + } + + // If regular file already exists + if (fileType == FileType.REGULAR) { + return null; + } + + // Create the file parent directory + errmsg = createParentDirectoryFile(context, label + "regular file parent", filePath); + if(errmsg != null) + return errmsg; + + try { + Logger.logVerbose(LOG_TAG, "Creating " + label + "regular file at path \"" + filePath + "\""); + + if(!file.createNewFile()) + return context.getString(R.string.error_creating_file_failed, label + "regular file", filePath); + } catch (Exception e) { + return context.getString(R.string.error_creating_file_failed_with_exception, label + "regular file", filePath, e.getMessage()); + } + + return validateRegularFileExistenceAndPermissions(context, label, filePath, + null, + permissionsToCheck, setPermissions, setMissingPermissionsOnly, + false); + } + + + + /** + * Create parent directory of file at path. + * + * This function is a wrapper for + * {@link #validateDirectoryFileExistenceAndPermissions(Context, String, String, String, boolean, String, boolean, boolean, boolean, boolean)}. + * + * @param context The {@link Context} to get error string. + * @param label The optional label for the parent directory file. This can optionally be {@code null}. + * @param filePath The {@code path} for file whose parent needs to be created. + * @return Returns the {@code errmsg} if parent path is not a directory file or failed to create it, + * otherwise {@code null}. + */ + public static String createParentDirectoryFile(@NonNull final Context context, final String label, final String filePath) { + if (filePath == null || filePath.isEmpty()) return context.getString(R.string.error_null_or_empty_parameter, label + "file path", "createParentDirectoryFile"); + + File file = new File(filePath); + String fileParentPath = file.getParent(); + + if(fileParentPath != null) + return createDirectoryFile(context, label, fileParentPath, + null, false, false); + else + return null; + } + + /** + * Create a directory file at path. + * + * This function is a wrapper for + * {@link #validateDirectoryFileExistenceAndPermissions(Context, String, String, String, boolean, String, boolean, boolean, boolean, boolean)}. + * + * @param context The {@link Context} to get error string. + * @param filePath The {@code path} for directory file to create. + * @return Returns the {@code errmsg} if path is not a directory file or failed to create it, + * otherwise {@code null}. + */ + public static String createDirectoryFile(@NonNull final Context context, final String filePath) { + return createDirectoryFile(context, null, filePath); + } + + /** + * Create a directory file at path. + * + * This function is a wrapper for + * {@link #validateDirectoryFileExistenceAndPermissions(Context, String, String, String, boolean, String, boolean, boolean, boolean, boolean)}. + * + * @param context The {@link Context} to get error string. + * @param label The optional label for the directory file. This can optionally be {@code null}. + * @param filePath The {@code path} for directory file to create. + * @return Returns the {@code errmsg} if path is not a directory file or failed to create it, + * otherwise {@code null}. + */ + public static String createDirectoryFile(@NonNull final Context context, final String label, final String filePath) { + return createDirectoryFile(context, label, filePath, + null, false, false); + } + + /** + * Create a directory file at path. + * + * This function is a wrapper for + * {@link #validateDirectoryFileExistenceAndPermissions(Context, String, String, String, boolean, String, boolean, boolean, boolean, boolean)}. + * + * @param context The {@link Context} to get error string. + * @param label The optional label for the directory file. This can optionally be {@code null}. + * @param filePath The {@code path} for directory file to create. + * @param permissionsToCheck The 3 character string that contains the "r", "w", "x" or "-" in-order. + * @param setPermissions The {@code boolean} that decides if permissions are to be + * automatically set defined by {@code permissionsToCheck}. + * @param setMissingPermissionsOnly The {@code boolean} that decides if only missing permissions + * are to be set or if they should be overridden. + * @return Returns the {@code errmsg} if path is not a directory file, failed to create it, + * or validating permissions failed, otherwise {@code null}. + */ + public static String createDirectoryFile(@NonNull final Context context, final String label, final String filePath, + final String permissionsToCheck, final boolean setPermissions, final boolean setMissingPermissionsOnly) { + return validateDirectoryFileExistenceAndPermissions(context, label, filePath, + null, true, + permissionsToCheck, setPermissions, setMissingPermissionsOnly, + false, false); + } + + + + /** + * Create a symlink file at path. + * + * This function is a wrapper for + * {@link #createSymlinkFile(Context, String, String, String, boolean, boolean, boolean)}. + * + * Dangling symlinks will be allowed. + * Symlink destination will be overwritten if it already exists but only if its a symlink. + * + * @param context The {@link Context} to get error string. + * @param targetFilePath The {@code path} TO which the symlink file will be created. + * @param destFilePath The {@code path} AT which the symlink file will be created. + * @return Returns the {@code errmsg} if path is not a symlink file, failed to create it, + * otherwise {@code null}. + */ + public static String createSymlinkFile(@NonNull final Context context, final String targetFilePath, final String destFilePath) { + return createSymlinkFile(context, null, targetFilePath, destFilePath, + true, true, true); + } + + /** + * Create a symlink file at path. + * + * This function is a wrapper for + * {@link #createSymlinkFile(Context, String, String, String, boolean, boolean, boolean)}. + * + * Dangling symlinks will be allowed. + * Symlink destination will be overwritten if it already exists but only if its a symlink. + * + * @param context The {@link Context} to get error string. + * @param label The optional label for the symlink file. This can optionally be {@code null}. + * @param targetFilePath The {@code path} TO which the symlink file will be created. + * @param destFilePath The {@code path} AT which the symlink file will be created. + * @return Returns the {@code errmsg} if path is not a symlink file, failed to create it, + * otherwise {@code null}. + */ + public static String createSymlinkFile(@NonNull final Context context, String label, final String targetFilePath, final String destFilePath) { + return createSymlinkFile(context, label, targetFilePath, destFilePath, + true, true, true); + } + + /** + * Create a symlink file at path. + * + * @param context The {@link Context} to get error string. + * @param label The optional label for the symlink file. This can optionally be {@code null}. + * @param targetFilePath The {@code path} TO which the symlink file will be created. + * @param destFilePath The {@code path} AT which the symlink file will be created. + * @param allowDangling The {@code boolean} that decides if it should be considered an + * error if source file doesn't exist. + * @param overwrite The {@code boolean} that decides if destination file should be overwritten if + * it already exists. If set to {@code true}, then destination file will be + * deleted before symlink is created. + * @param overwriteOnlyIfDestIsASymlink The {@code boolean} that decides if overwrite should + * only be done if destination file is also a symlink. + * @return Returns the {@code errmsg} if path is not a symlink file, failed to create it, + * or validating permissions failed, otherwise {@code null}. + */ + public static String createSymlinkFile(@NonNull final Context context, String label, final String targetFilePath, final String destFilePath, + final boolean allowDangling, final boolean overwrite, final boolean overwriteOnlyIfDestIsASymlink) { + label = (label == null ? "" : label + " "); + if (targetFilePath == null || targetFilePath.isEmpty()) return context.getString(R.string.error_null_or_empty_parameter, label + "target file path", "createSymlinkFile"); + if (destFilePath == null || destFilePath.isEmpty()) return context.getString(R.string.error_null_or_empty_parameter, label + "destination file path", "createSymlinkFile"); + + String errmsg; + + try { + File destFile = new File(destFilePath); + + String targetFileAbsolutePath = targetFilePath; + // If target path is relative instead of absolute + if (!targetFilePath.startsWith("/")) { + String destFileParentPath = destFile.getParent(); + if(destFileParentPath != null) + targetFileAbsolutePath = destFileParentPath + "/" + targetFilePath; + } + + FileType targetFileType = getFileType(targetFileAbsolutePath, false); + FileType destFileType = getFileType(destFilePath, false); + + // If target file does not exist + if (targetFileType == FileType.NO_EXIST) { + // If dangling symlink should not be allowed, then return with error + if(!allowDangling) + return context.getString(R.string.error_file_not_found_at_path, label + "symlink target file", targetFileAbsolutePath); + } + + // If destination exists + if(destFileType != FileType.NO_EXIST) { + // If destination must not be overwritten + if(!overwrite) { + return null; + } + + // If overwriteOnlyIfDestIsASymlink is enabled but destination file is not a symlink + if(overwriteOnlyIfDestIsASymlink && destFileType != FileType.SYMLINK) + return context.getString(R.string.error_cannot_overwrite_a_non_symlink_file_type, label + " file", destFilePath, targetFilePath, destFileType.getName()); + + // Delete the destination file + errmsg = deleteFile(context, label + "symlink destination", destFilePath, true); + if(errmsg != null) + return errmsg; + } else { + // Create the destination file parent directory + errmsg = createParentDirectoryFile(context, label + "symlink destination file parent", destFilePath); + if(errmsg != null) + return errmsg; + } + + // create a symlink at destFilePath to targetFilePath + Logger.logVerbose(LOG_TAG, "Creating " + label + "symlink file at path \"" + destFilePath + "\" to \"" + targetFilePath + "\""); + Os.symlink(targetFilePath, destFilePath); + } catch (Exception e) { + return context.getString(R.string.error_creating_symlink_file_failed_with_exception, label + "symlink file", destFilePath, targetFilePath, e.getMessage()); + } + + return null; + } + + + + /** + * Copy a regular file from {@code sourceFilePath} to {@code destFilePath}. + * + * This function is a wrapper for + * {@link #copyOrMoveFile(Context, String, String, String, boolean, boolean, int, boolean, boolean)}. + * + * If destination file already exists, then it will be overwritten, but only if its a regular + * file, otherwise an error will be returned. + * + * @param context The {@link Context} to get error string. + * @param label The optional label for file to copy. This can optionally be {@code null}. + * @param srcFilePath The {@code source path} for file to copy. + * @param destFilePath The {@code destination path} for file to copy. + * @param ignoreNonExistentSrcFile The {@code boolean} that decides if it should be considered an + * error if source file to copied doesn't exist. + * @return Returns the {@code errmsg} if copy was not successful, otherwise {@code null}. + */ + public static String copyRegularFile(@NonNull final Context context, final String label, final String srcFilePath, final String destFilePath, final boolean ignoreNonExistentSrcFile) { + return copyOrMoveFile(context, label, srcFilePath, destFilePath, + false, ignoreNonExistentSrcFile, FileType.REGULAR.getValue(), + true, true); + } + + /** + * Move a regular file from {@code sourceFilePath} to {@code destFilePath}. + * + * This function is a wrapper for + * {@link #copyOrMoveFile(Context, String, String, String, boolean, boolean, int, boolean, boolean)}. + * + * If destination file already exists, then it will be overwritten, but only if its a regular + * file, otherwise an error will be returned. + * + * @param context The {@link Context} to get error string. + * @param label The optional label for file to move. This can optionally be {@code null}. + * @param srcFilePath The {@code source path} for file to move. + * @param destFilePath The {@code destination path} for file to move. + * @param ignoreNonExistentSrcFile The {@code boolean} that decides if it should be considered an + * error if source file to moved doesn't exist. + * @return Returns the {@code errmsg} if move was not successful, otherwise {@code null}. + */ + public static String moveRegularFile(@NonNull final Context context, final String label, final String srcFilePath, final String destFilePath, final boolean ignoreNonExistentSrcFile) { + return copyOrMoveFile(context, label, srcFilePath, destFilePath, + true, ignoreNonExistentSrcFile, FileType.REGULAR.getValue(), + true, true); + } + + /** + * Copy a directory file from {@code sourceFilePath} to {@code destFilePath}. + * + * This function is a wrapper for + * {@link #copyOrMoveFile(Context, String, String, String, boolean, boolean, int, boolean, boolean)}. + * + * If destination file already exists, then it will be overwritten, but only if its a directory + * file, otherwise an error will be returned. + * + * @param context The {@link Context} to get error string. + * @param label The optional label for file to copy. This can optionally be {@code null}. + * @param srcFilePath The {@code source path} for file to copy. + * @param destFilePath The {@code destination path} for file to copy. + * @param ignoreNonExistentSrcFile The {@code boolean} that decides if it should be considered an + * error if source file to copied doesn't exist. + * @return Returns the {@code errmsg} if copy was not successful, otherwise {@code null}. + */ + public static String copyDirectoryFile(@NonNull final Context context, final String label, final String srcFilePath, final String destFilePath, final boolean ignoreNonExistentSrcFile) { + return copyOrMoveFile(context, label, srcFilePath, destFilePath, + false, ignoreNonExistentSrcFile, FileType.DIRECTORY.getValue(), + true, true); + } + + /** + * Move a directory file from {@code sourceFilePath} to {@code destFilePath}. + * + * This function is a wrapper for + * {@link #copyOrMoveFile(Context, String, String, String, boolean, boolean, int, boolean, boolean)}. + * + * If destination file already exists, then it will be overwritten, but only if its a directory + * file, otherwise an error will be returned. + * + * @param context The {@link Context} to get error string. + * @param label The optional label for file to move. This can optionally be {@code null}. + * @param srcFilePath The {@code source path} for file to move. + * @param destFilePath The {@code destination path} for file to move. + * @param ignoreNonExistentSrcFile The {@code boolean} that decides if it should be considered an + * error if source file to moved doesn't exist. + * @return Returns the {@code errmsg} if move was not successful, otherwise {@code null}. + */ + public static String moveDirectoryFile(@NonNull final Context context, final String label, final String srcFilePath, final String destFilePath, final boolean ignoreNonExistentSrcFile) { + return copyOrMoveFile(context, label, srcFilePath, destFilePath, + true, ignoreNonExistentSrcFile, FileType.DIRECTORY.getValue(), + true, true); + } + + /** + * Copy a symlink file from {@code sourceFilePath} to {@code destFilePath}. + * + * This function is a wrapper for + * {@link #copyOrMoveFile(Context, String, String, String, boolean, boolean, int, boolean, boolean)}. + * + * If destination file already exists, then it will be overwritten, but only if its a symlink + * file, otherwise an error will be returned. + * + * @param context The {@link Context} to get error string. + * @param label The optional label for file to copy. This can optionally be {@code null}. + * @param srcFilePath The {@code source path} for file to copy. + * @param destFilePath The {@code destination path} for file to copy. + * @param ignoreNonExistentSrcFile The {@code boolean} that decides if it should be considered an + * error if source file to copied doesn't exist. + * @return Returns the {@code errmsg} if copy was not successful, otherwise {@code null}. + */ + public static String copySymlinkFile(@NonNull final Context context, final String label, final String srcFilePath, final String destFilePath, final boolean ignoreNonExistentSrcFile) { + return copyOrMoveFile(context, label, srcFilePath, destFilePath, + false, ignoreNonExistentSrcFile, FileType.SYMLINK.getValue(), + true, true); + } + + /** + * Move a symlink file from {@code sourceFilePath} to {@code destFilePath}. + * + * This function is a wrapper for + * {@link #copyOrMoveFile(Context, String, String, String, boolean, boolean, int, boolean, boolean)}. + * + * If destination file already exists, then it will be overwritten, but only if its a symlink + * file, otherwise an error will be returned. + * + * @param context The {@link Context} to get error string. + * @param label The optional label for file to move. This can optionally be {@code null}. + * @param srcFilePath The {@code source path} for file to move. + * @param destFilePath The {@code destination path} for file to move. + * @param ignoreNonExistentSrcFile The {@code boolean} that decides if it should be considered an + * error if source file to moved doesn't exist. + * @return Returns the {@code errmsg} if move was not successful, otherwise {@code null}. + */ + public static String moveSymlinkFile(@NonNull final Context context, final String label, final String srcFilePath, final String destFilePath, final boolean ignoreNonExistentSrcFile) { + return copyOrMoveFile(context, label, srcFilePath, destFilePath, + true, ignoreNonExistentSrcFile, FileType.SYMLINK.getValue(), + true, true); + } + + /** + * Copy a file from {@code sourceFilePath} to {@code destFilePath}. + * + * This function is a wrapper for + * {@link #copyOrMoveFile(Context, String, String, String, boolean, boolean, int, boolean, boolean)}. + * + * If destination file already exists, then it will be overwritten, but only if its the same file + * type as the source, otherwise an error will be returned. + * + * @param context The {@link Context} to get error string. + * @param label The optional label for file to copy. This can optionally be {@code null}. + * @param srcFilePath The {@code source path} for file to copy. + * @param destFilePath The {@code destination path} for file to copy. + * @param ignoreNonExistentSrcFile The {@code boolean} that decides if it should be considered an + * error if source file to copied doesn't exist. + * @return Returns the {@code errmsg} if copy was not successful, otherwise {@code null}. + */ + public static String copyFile(@NonNull final Context context, final String label, final String srcFilePath, final String destFilePath, final boolean ignoreNonExistentSrcFile) { + return copyOrMoveFile(context, label, srcFilePath, destFilePath, + false, ignoreNonExistentSrcFile, FileTypes.FILE_TYPE_NORMAL_FLAGS, + true, true); + } + + /** + * Move a file from {@code sourceFilePath} to {@code destFilePath}. + * + * This function is a wrapper for + * {@link #copyOrMoveFile(Context, String, String, String, boolean, boolean, int, boolean, boolean)}. + * + * If destination file already exists, then it will be overwritten, but only if its the same file + * type as the source, otherwise an error will be returned. + * + * @param context The {@link Context} to get error string. + * @param label The optional label for file to move. This can optionally be {@code null}. + * @param srcFilePath The {@code source path} for file to move. + * @param destFilePath The {@code destination path} for file to move. + * @param ignoreNonExistentSrcFile The {@code boolean} that decides if it should be considered an + * error if source file to moved doesn't exist. + * @return Returns the {@code errmsg} if move was not successful, otherwise {@code null}. + */ + public static String moveFile(@NonNull final Context context, final String label, final String srcFilePath, final String destFilePath, final boolean ignoreNonExistentSrcFile) { + return copyOrMoveFile(context, label, srcFilePath, destFilePath, + true, ignoreNonExistentSrcFile, FileTypes.FILE_TYPE_NORMAL_FLAGS, + true, true); + } + + /** + * Copy or move a file from {@code sourceFilePath} to {@code destFilePath}. + * + * The {@code sourceFilePath} and {@code destFilePath} must be the canonical path to the source + * and destination since symlinks will not be followed. + * + * If the {@code sourceFilePath} or {@code destFilePath} is a canonical path to a directory, + * then any symlink files found under the directory will be deleted, but not their targets when + * deleting source after move and deleting destination before copy/move. + * + * @param context The {@link Context} to get error string. + * @param label The optional label for file to copy or move. This can optionally be {@code null}. + * @param srcFilePath The {@code source path} for file to copy or move. + * @param destFilePath The {@code destination path} for file to copy or move. + * @param moveFile The {@code boolean} that decides if source file needs to be copied or moved. + * If set to {@code true}, then source file will be moved, otherwise it will be + * copied. + * @param ignoreNonExistentSrcFile The {@code boolean} that decides if it should be considered an + * error if source file to copied or moved doesn't exist. + * @param allowedFileTypeFlags The flags that are matched against the source file's {@link FileType} + * to see if it should be copied/moved or not. This is a safety measure + * to prevent accidental copy/move/delete of the wrong type of file, + * like a directory instead of a regular file. You can pass + * {@link FileTypes#FILE_TYPE_ANY_FLAGS} to allow copy/move of any file type. + * @param overwrite The {@code boolean} that decides if destination file should be overwritten if + * it already exists. If set to {@code true}, then destination file will be + * deleted before source is copied or moved. + * @param overwriteOnlyIfDestSameFileTypeAsSrc The {@code boolean} that decides if overwrite should + * only be done if destination file is also the same file + * type as the source file. + * @return Returns the {@code errmsg} if copy or move was not successful, otherwise {@code null}. + */ + public static String copyOrMoveFile(@NonNull final Context context, String label, final String srcFilePath, final String destFilePath, + final boolean moveFile, final boolean ignoreNonExistentSrcFile, int allowedFileTypeFlags, + final boolean overwrite, final boolean overwriteOnlyIfDestSameFileTypeAsSrc) { + label = (label == null ? "" : label + " "); + if (srcFilePath == null || srcFilePath.isEmpty()) return context.getString(R.string.error_null_or_empty_parameter, label + "source file path", "copyOrMoveFile"); + if (destFilePath == null || destFilePath.isEmpty()) return context.getString(R.string.error_null_or_empty_parameter, label + "destination file path", "copyOrMoveFile"); + + String mode = (moveFile ? "Moving" : "Copying"); + String modePast = (moveFile ? "moved" : "copied"); + + String errmsg; + + InputStream inputStream = null; + OutputStream outputStream = null; + + try { + Logger.logVerbose(LOG_TAG, mode + " " + label + "source file from \"" + srcFilePath + "\" to destination \"" + destFilePath + "\""); + + File srcFile = new File(srcFilePath); + File destFile = new File(destFilePath); + + FileType srcFileType = getFileType(srcFilePath, false); + FileType destFileType = getFileType(destFilePath, false); + + String srcFileCanonicalPath = srcFile.getCanonicalPath(); + String destFileCanonicalPath = destFile.getCanonicalPath(); + + // If source file does not exist + if (srcFileType == FileType.NO_EXIST) { + // If copy or move is to be ignored if source file is not found + if(ignoreNonExistentSrcFile) + return null; + // Else return with error + else + return context.getString(R.string.error_file_not_found_at_path, label + "source file", srcFilePath); + } + + // If the file type of the source file does not exist in the allowedFileTypeFlags, then return with error + if((allowedFileTypeFlags & srcFileType.getValue()) <= 0) + return context.getString(R.string.error_file_not_an_allowed_file_type, label + "source file meant to be " + modePast, srcFilePath, FileTypes.convertFileTypeFlagsToNamesString(allowedFileTypeFlags)); + + // If source and destination file path are the same + if (srcFileCanonicalPath.equals(destFileCanonicalPath)) + return context.getString(R.string.error_copying_or_moving_file_to_same_path, mode + " " + label + "source file", srcFilePath, destFilePath); + + // If destination exists + if(destFileType != FileType.NO_EXIST) { + // If destination must not be overwritten + if(!overwrite) { + return null; + } + + // If overwriteOnlyIfDestSameFileTypeAsSrc is enabled but destination file does not match source file type + if(overwriteOnlyIfDestSameFileTypeAsSrc && destFileType != srcFileType) + return context.getString(R.string.error_cannot_overwrite_a_different_file_type, label + "source file", mode.toLowerCase(), srcFilePath, destFilePath, destFileType.getName(), srcFileType.getName()); + + // Delete the destination file + errmsg = deleteFile(context, label + "destination file", destFilePath, true); + if(errmsg != null) + return errmsg; + } + + + // Copy or move source file to dest + boolean copyFile = !moveFile; + + // If moveFile is true + if(moveFile) { + // We first try to rename source file to destination file to save a copy operation in case both source and destination are on the same filesystem + Logger.logVerbose(LOG_TAG, "Attempting to rename source to destination."); + + // https://cs.android.com/android/platform/superproject/+/android-11.0.0_r3:libcore/ojluni/src/main/java/java/io/UnixFileSystem.java;l=358 + // https://cs.android.com/android/platform/superproject/+/android-11.0.0_r3:libcore/luni/src/main/java/android/system/Os.java;l=512 + // Uses File.getPath() to get the path of source and destination and not the canonical path + if(!srcFile.renameTo(destFile)) { + // If destination directory is a subdirectory of the source directory + // Copying is still allowed by copyDirectory() by excluding destination directory files + if (srcFileType == FileType.DIRECTORY && destFileCanonicalPath.startsWith(srcFileCanonicalPath + File.separator)) + return context.getString(R.string.error_cannot_move_directory_to_sub_directory_of_itself, label + "source directory", srcFilePath, destFilePath); + + // If rename failed, then we copy + Logger.logVerbose(LOG_TAG, "Renaming " + label + "source file to destination failed, attempting to copy."); + copyFile = true; + } + } + + // If moveFile is false or renameTo failed while moving + if(copyFile) { + Logger.logVerbose(LOG_TAG, "Attempting to copy source to destination."); + + // Create the dest file parent directory + errmsg = createParentDirectoryFile(context, label + "dest file parent", destFilePath); + if(errmsg != null) + return errmsg; + + if (srcFileType == FileType.DIRECTORY) { + // Will give runtime exceptions on android < 8 due to missing classes like java.nio.file.Path if org.apache.commons.io version > 2.5 + org.apache.commons.io.FileUtils.copyDirectory(srcFile, destFile, true); + } else if (srcFileType == FileType.SYMLINK) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + java.nio.file.Files.copy(srcFile.toPath(), destFile.toPath(), LinkOption.NOFOLLOW_LINKS, StandardCopyOption.REPLACE_EXISTING); + } else { + // read the target for the source file and create a symlink at dest + // source file metadata will be lost + errmsg = createSymlinkFile(context, label + "dest file", Os.readlink(srcFilePath), destFilePath); + if (errmsg != null) + return errmsg; + } + } else { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + java.nio.file.Files.copy(srcFile.toPath(), destFile.toPath(), LinkOption.NOFOLLOW_LINKS, StandardCopyOption.REPLACE_EXISTING); + } else { + // Will give runtime exceptions on android < 8 due to missing classes like java.nio.file.Path if org.apache.commons.io version > 2.5 + org.apache.commons.io.FileUtils.copyFile(srcFile, destFile, true); + } + } + } + + // If source file had to be moved + if(moveFile) { + // Delete the source file since copying would have succeeded + errmsg = deleteFile(context, label + "source file", srcFilePath, true); + if(errmsg != null) + return errmsg; + } + + Logger.logVerbose(LOG_TAG, mode + " successful."); + } + catch (Exception e) { + return context.getString(R.string.error_copying_or_moving_file_failed_with_exception, mode + " " + label + "file", srcFilePath, destFilePath, e.getMessage()); + } finally { + closeCloseable(inputStream); + closeCloseable(outputStream); + } + + return null; + } + + + + /** + * Delete regular file at path. + * + * This function is a wrapper for {@link #deleteFile(Context, String, String, boolean, int)}. + * + * @param context The {@link Context} to get error string. + * @param label The optional label for file to delete. This can optionally be {@code null}. + * @param filePath The {@code path} for file to delete. + * @param ignoreNonExistentFile The {@code boolean} that decides if it should be considered an + * error if file to deleted doesn't exist. + * @return Returns the {@code errmsg} if deletion was not successful, otherwise {@code null}. + */ + public static String deleteRegularFile(@NonNull final Context context, String label, final String filePath, final boolean ignoreNonExistentFile) { + return deleteFile(context, label, filePath, ignoreNonExistentFile, FileType.REGULAR.getValue()); + } + + /** + * Delete directory file at path. + * + * This function is a wrapper for {@link #deleteFile(Context, String, String, boolean, int)}. + * + * @param context The {@link Context} to get error string. + * @param label The optional label for file to delete. This can optionally be {@code null}. + * @param filePath The {@code path} for file to delete. + * @param ignoreNonExistentFile The {@code boolean} that decides if it should be considered an + * error if file to deleted doesn't exist. + * @return Returns the {@code errmsg} if deletion was not successful, otherwise {@code null}. + */ + public static String deleteDirectoryFile(@NonNull final Context context, String label, final String filePath, final boolean ignoreNonExistentFile) { + return deleteFile(context, label, filePath, ignoreNonExistentFile, FileType.DIRECTORY.getValue()); + } + + /** + * Delete symlink file at path. + * + * This function is a wrapper for {@link #deleteFile(Context, String, String, boolean, int)}. + * + * @param context The {@link Context} to get error string. + * @param label The optional label for file to delete. This can optionally be {@code null}. + * @param filePath The {@code path} for file to delete. + * @param ignoreNonExistentFile The {@code boolean} that decides if it should be considered an + * error if file to deleted doesn't exist. + * @return Returns the {@code errmsg} if deletion was not successful, otherwise {@code null}. + */ + public static String deleteSymlinkFile(@NonNull final Context context, String label, final String filePath, final boolean ignoreNonExistentFile) { + return deleteFile(context, label, filePath, ignoreNonExistentFile, FileType.SYMLINK.getValue()); + } + + /** + * Delete regular, directory or symlink file at path. + * + * This function is a wrapper for {@link #deleteFile(Context, String, String, boolean, int)}. + * + * @param context The {@link Context} to get error string. + * @param label The optional label for file to delete. This can optionally be {@code null}. + * @param filePath The {@code path} for file to delete. + * @param ignoreNonExistentFile The {@code boolean} that decides if it should be considered an + * error if file to deleted doesn't exist. + * @return Returns the {@code errmsg} if deletion was not successful, otherwise {@code null}. + */ + public static String deleteFile(@NonNull final Context context, String label, final String filePath, final boolean ignoreNonExistentFile) { + return deleteFile(context, label, filePath, ignoreNonExistentFile, FileTypes.FILE_TYPE_NORMAL_FLAGS); + } + + /** + * Delete file at path. + * + * The {@code filePath} must be the canonical path to the file to be deleted since symlinks will + * not be followed. + * If the {@code filePath} is a canonical path to a directory, then any symlink files found under + * the directory will be deleted, but not their targets. + * + * @param context The {@link Context} to get error string. + * @param label The optional label for file to delete. This can optionally be {@code null}. + * @param filePath The {@code path} for file to delete. + * @param ignoreNonExistentFile The {@code boolean} that decides if it should be considered an + * error if file to deleted doesn't exist. + * @param allowedFileTypeFlags The flags that are matched against the file's {@link FileType} to + * see if it should be deleted or not. This is a safety measure to + * prevent accidental deletion of the wrong type of file, like a + * directory instead of a regular file. You can pass + * {@link FileTypes#FILE_TYPE_ANY_FLAGS} to allow deletion of any file type. + * @return Returns the {@code errmsg} if deletion was not successful, otherwise {@code null}. + */ + public static String deleteFile(@NonNull final Context context, String label, final String filePath, final boolean ignoreNonExistentFile, int allowedFileTypeFlags) { + label = (label == null ? "" : label + " "); + if (filePath == null || filePath.isEmpty()) return context.getString(R.string.error_null_or_empty_parameter, label + "file path", "deleteFile"); + + try { + Logger.logVerbose(LOG_TAG, "Deleting " + label + "file at path \"" + filePath + "\""); + + File file = new File(filePath); + FileType fileType = getFileType(filePath, false); + + // If file does not exist + if (fileType == FileType.NO_EXIST) { + // If delete is to be ignored if file does not exist + if(ignoreNonExistentFile) + return null; + // Else return with error + else + return context.getString(R.string.error_file_not_found_at_path, label + "file meant to be deleted", filePath); + } + + // If the file type of the file does not exist in the allowedFileTypeFlags, then return with error + if((allowedFileTypeFlags & fileType.getValue()) <= 0) + return context.getString(R.string.error_file_not_an_allowed_file_type, label + "file meant to be deleted", filePath, FileTypes.convertFileTypeFlagsToNamesString(allowedFileTypeFlags)); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + /* Try to use {@link SecureDirectoryStream} if available for safer directory + deletion, it should be available for android >= 8.0 + * https://guava.dev/releases/24.1-jre/api/docs/com/google/common/io/MoreFiles.html#deleteRecursively-java.nio.file.Path-com.google.common.io.RecursiveDeleteOption...- + * https://github.com/google/guava/issues/365 + * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r3:libcore/ojluni/src/main/java/sun/nio/fs/UnixSecureDirectoryStream.java + * + * MoreUtils is marked with the @Beta annotation so the API may be removed in + * future but has been there for a few years now + */ + //noinspection UnstableApiUsage + com.google.common.io.MoreFiles.deleteRecursively(file.toPath(), RecursiveDeleteOption.ALLOW_INSECURE); + } else { + if (fileType == FileType.DIRECTORY) { + // deleteDirectory() instead of forceDelete() gets the files list first instead of walking directory tree, so seems safer + // Will give runtime exceptions on android < 8 due to missing classes like java.nio.file.Path if org.apache.commons.io version > 2.5 + org.apache.commons.io.FileUtils.deleteDirectory(file); + } else { + // Will give runtime exceptions on android < 8 due to missing classes like java.nio.file.Path if org.apache.commons.io version > 2.5 + org.apache.commons.io.FileUtils.forceDelete(file); + } + } + + // If file still exists after deleting it + fileType = getFileType(filePath, false); + if(fileType != FileType.NO_EXIST) + return context.getString(R.string.error_file_still_exists_after_deleting, label + "file meant to be deleted", filePath); + } + catch (Exception e) { + return context.getString(R.string.error_deleting_file_failed_with_exception, label + "file", filePath, e.getMessage()); + } + + return null; + } + + + + /** + * Clear contents of directory at path without deleting the directory. If directory does not exist + * it will be created automatically. + * + * This function is a wrapper for + * {@link #clearDirectory(Context, String, String)}. + * + * @param context The {@link Context} to get error string. + * @param filePath The {@code path} for directory to clear. + * @return Returns the {@code errmsg} if clearing was not successful, otherwise {@code null}. + */ + public static String clearDirectory(Context context, String filePath) { + return clearDirectory(context, null, filePath); + } + + /** + * Clear contents of directory at path without deleting the directory. If directory does not exist + * it will be created automatically. + * + * The {@code filePath} must be the canonical path to a directory since symlinks will not be followed. + * Any symlink files found under the directory will be deleted, but not their targets. + * + * @param context The {@link Context} to get error string. + * @param label The optional label for directory to clear. This can optionally be {@code null}. + * @param filePath The {@code path} for directory to clear. + * @return Returns the {@code errmsg} if clearing was not successful, otherwise {@code null}. + */ + public static String clearDirectory(@NonNull final Context context, String label, final String filePath) { + label = (label == null ? "" : label + " "); + if (filePath == null || filePath.isEmpty()) return context.getString(R.string.error_null_or_empty_parameter, label + "file path", "clearDirectory"); + + String errmsg; + + try { + Logger.logVerbose(LOG_TAG, "Clearing " + label + "directory at path \"" + filePath + "\""); + + File file = new File(filePath); + FileType fileType = getFileType(filePath, false); + + // If file exists but not a directory file + if (fileType != FileType.NO_EXIST && fileType != FileType.DIRECTORY) { + return context.getString(R.string.error_non_directory_file_found, label + "directory"); + } + + // If directory exists, clear its contents + if (fileType == FileType.DIRECTORY) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + //noinspection UnstableApiUsage + com.google.common.io.MoreFiles.deleteDirectoryContents(file.toPath(), RecursiveDeleteOption.ALLOW_INSECURE); + } else { + // Will give runtime exceptions on android < 8 due to missing classes like java.nio.file.Path if org.apache.commons.io version > 2.5 + org.apache.commons.io.FileUtils.cleanDirectory(new File(filePath)); + } + } + // Else create it + else { + errmsg = createDirectoryFile(context, label, filePath); + if(errmsg != null) + return errmsg; + } + } catch (Exception e) { + return context.getString(R.string.error_clearing_directory_failed_with_exception, label + "directory", filePath, e.getMessage()); + } + + return null; + } + + + + /** + * Read a {@link String} from file at path with a specific {@link Charset} into {@code dataString}. + * + * @param context The {@link Context} to get error string. + * @param label The optional label for file to read. This can optionally be {@code null}. + * @param filePath The {@code path} for file to read. + * @param charset The {@link Charset} of the file. If this is {@code null}, + * * then default {@link Charset} will be used. + * @param dataStringBuilder The {@code StringBuilder} to read data into. + * @param ignoreNonExistentFile The {@code boolean} that decides if it should be considered an + * error if file to read doesn't exist. + * @return Returns the {@code errmsg} if reading was not successful, otherwise {@code null}. + */ + public static String readStringFromFile(@NonNull final Context context, String label, final String filePath, Charset charset, @NonNull final StringBuilder dataStringBuilder, final boolean ignoreNonExistentFile) { + label = (label == null ? "" : label + " "); + if (filePath == null || filePath.isEmpty()) return context.getString(R.string.error_null_or_empty_parameter, label + "file path", "readStringFromFile"); + + Logger.logVerbose(LOG_TAG, "Reading string from " + label + "file at path \"" + filePath + "\""); + + String errmsg; + + FileType fileType = getFileType(filePath, false); + + // If file exists but not a regular file + if (fileType != FileType.NO_EXIST && fileType != FileType.REGULAR) { + return context.getString(R.string.error_non_regular_file_found, label + "file"); + } + + // If file does not exist + if (fileType == FileType.NO_EXIST) { + // If reading is to be ignored if file does not exist + if(ignoreNonExistentFile) + return null; + // Else return with error + else + return context.getString(R.string.error_file_not_found_at_path, label + "file meant to be read", filePath); + } + + if(charset == null) charset = Charset.defaultCharset(); + + // Check if charset is supported + errmsg = isCharsetSupported(context, charset); + if(errmsg != null) + return errmsg; + + FileInputStream fileInputStream = null; + BufferedReader bufferedReader = null; + try { + // Read string from file + fileInputStream = new FileInputStream(filePath); + bufferedReader = new BufferedReader(new InputStreamReader(fileInputStream, charset)); + + String receiveString; + + boolean firstLine = true; + while ((receiveString = bufferedReader.readLine()) != null ) { + if(!firstLine) dataStringBuilder.append("\n"); else firstLine = false; + dataStringBuilder.append(receiveString); + } + + Logger.logVerbose(LOG_TAG, Logger.getMultiLineLogStringEntry("String", DataUtils.getTruncatedCommandOutput(dataStringBuilder.toString(), Logger.LOGGER_ENTRY_SIZE_LIMIT_IN_BYTES, true, false, true), "-")); + } catch (Exception e) { + return context.getString(R.string.error_reading_string_to_file_failed_with_exception, label + "file", filePath, e.getMessage()); + } finally { + closeCloseable(fileInputStream); + closeCloseable(bufferedReader); + } + + return null; + } + + /** + * Write the {@link String} {@code dataString} with a specific {@link Charset} to file at path. + * + * @param context The {@link Context} to get error string. + * @param label The optional label for file to write. This can optionally be {@code null}. + * @param filePath The {@code path} for file to write. + * @param charset The {@link Charset} of the {@code dataString}. If this is {@code null}, + * then default {@link Charset} will be used. + * @param append The {@code boolean} that decides if file should be appended to or not. + * @return Returns the {@code errmsg} if writing was not successful, otherwise {@code null}. + */ + public static String writeStringToFile(@NonNull final Context context, String label, final String filePath, Charset charset, final String dataString, final boolean append) { + label = (label == null ? "" : label + " "); + if (filePath == null || filePath.isEmpty()) return context.getString(R.string.error_null_or_empty_parameter, label + "file path", "writeStringToFile"); + + Logger.logVerbose(LOG_TAG, Logger.getMultiLineLogStringEntry("Writing string to " + label + "file at path \"" + filePath + "\"", DataUtils.getTruncatedCommandOutput(dataString, Logger.LOGGER_ENTRY_SIZE_LIMIT_IN_BYTES, true, false, true), "-")); + + String errmsg; + + FileType fileType = getFileType(filePath, false); + + // If file exists but not a regular file + if (fileType != FileType.NO_EXIST && fileType != FileType.REGULAR) { + return context.getString(R.string.error_non_regular_file_found, label + "file"); + } + + // Create the file parent directory + errmsg = createParentDirectoryFile(context, label + "file parent", filePath); + if(errmsg != null) + return errmsg; + + if(charset == null) charset = Charset.defaultCharset(); + + // Check if charset is supported + errmsg = isCharsetSupported(context, charset); + if(errmsg != null) + return errmsg; + + FileOutputStream fileOutputStream = null; + BufferedWriter bufferedWriter = null; + try { + // Write string to file + fileOutputStream = new FileOutputStream(filePath, append); + bufferedWriter = new BufferedWriter(new OutputStreamWriter(fileOutputStream, charset)); + + bufferedWriter.write(dataString); + bufferedWriter.flush(); + } catch (Exception e) { + return context.getString(R.string.error_writing_string_to_file_failed_with_exception, label + "file", filePath, e.getMessage()); + } finally { + closeCloseable(fileOutputStream); + closeCloseable(bufferedWriter); + } + + return null; + } + + + + /** + * Check if a specific {@link Charset} is supported. + * + * @param context The {@link Context} to get error string. + * @param charset The {@link Charset} to check. + * @return Returns the {@code errmsg} if charset is not supported or failed to check it, otherwise {@code null}. + */ + public static String isCharsetSupported(@NonNull final Context context, final Charset charset) { + if (charset == null) return context.getString(R.string.error_null_or_empty_parameter, "charset", "isCharsetSupported"); + + try { + if(!Charset.isSupported(charset.name())) { + return context.getString(R.string.error_unsupported_charset, charset.name()); + } + } catch (Exception e) { + return context.getString(R.string.error_checking_if_charset_supported_failed, charset.name(), e.getMessage()); + } + + return null; + } + + + + /** + * Close a {@link Closeable} object if not {@code null} and ignore any exceptions raised. + * + * @param closeable The {@link Closeable} object to close. + */ + public static void closeCloseable(final Closeable closeable) { + if (closeable != null) { + try { + closeable.close(); + } + catch (IOException e) { + // ignore + } + } + } + + + + /** + * Set permissions for file at path. Existing permission outside the {@code permissionsToSet} + * will be removed. + * + * @param filePath The {@code path} for file to set permissions to. + * @param permissionsToSet The 3 character string that contains the "r", "w", "x" or "-" in-order. + */ + public static void setFilePermissions(final String filePath, final String permissionsToSet) { + setFilePermissions(null, filePath, permissionsToSet); + } + + /** + * Set permissions for file at path. Existing permission outside the {@code permissionsToSet} + * will be removed. + * + * @param label The optional label for the file. This can optionally be {@code null}. + * @param filePath The {@code path} for file to set permissions to. + * @param permissionsToSet The 3 character string that contains the "r", "w", "x" or "-" in-order. + */ + public static void setFilePermissions(String label, final String filePath, final String permissionsToSet) { + label = (label == null ? "" : label + " "); + if (filePath == null || filePath.isEmpty()) return; + + if (!isValidPermissingString(permissionsToSet)) { + Logger.logError(LOG_TAG, "Invalid permissionsToSet passed to setFilePermissions: \"" + permissionsToSet + "\""); + return; + } + + File file = new File(filePath); + + if (permissionsToSet.contains("r")) { + if (!file.canRead()) { + Logger.logVerbose(LOG_TAG, "Setting read permissions for " + label + "file at path \"" + filePath + "\""); + file.setReadable(true); + } + } else { + if (file.canRead()) { + Logger.logVerbose(LOG_TAG, "Removing read permissions for " + label + "file at path \"" + filePath + "\""); + file.setReadable(false); + } + } + + + if (permissionsToSet.contains("w")) { + if (!file.canWrite()) { + Logger.logVerbose(LOG_TAG, "Setting write permissions for " + label + "file at path \"" + filePath + "\""); + file.setWritable(true); + } + } else { + if (file.canWrite()) { + Logger.logVerbose(LOG_TAG, "Removing write permissions for " + label + "file at path \"" + filePath + "\""); + file.setWritable(false); + } + } + + + if (permissionsToSet.contains("x")) { + if (!file.canExecute()) { + Logger.logVerbose(LOG_TAG, "Setting execute permissions for " + label + "file at path \"" + filePath + "\""); + file.setExecutable(true); + } + } else { + if (file.canExecute()) { + Logger.logVerbose(LOG_TAG, "Removing execute permissions for " + label + "file at path \"" + filePath + "\""); + file.setExecutable(false); + } + } + } + + + + /** + * Set missing permissions for file at path. Existing permission outside the {@code permissionsToSet} + * will not be removed. + * + * @param filePath The {@code path} for file to set permissions to. + * @param permissionsToSet The 3 character string that contains the "r", "w", "x" or "-" in-order. + */ + public static void setMissingFilePermissions(final String filePath, final String permissionsToSet) { + setMissingFilePermissions(null, filePath, permissionsToSet); + } + + /** + * Set missing permissions for file at path. Existing permission outside the {@code permissionsToSet} + * will not be removed. + * + * @param label The optional label for the file. This can optionally be {@code null}. + * @param filePath The {@code path} for file to set permissions to. + * @param permissionsToSet The 3 character string that contains the "r", "w", "x" or "-" in-order. + */ + public static void setMissingFilePermissions(String label, final String filePath, final String permissionsToSet) { + label = (label == null ? "" : label + " "); + if (filePath == null || filePath.isEmpty()) return; + + if (!isValidPermissingString(permissionsToSet)) { + Logger.logError(LOG_TAG, "Invalid permissionsToSet passed to setMissingFilePermissions: \"" + permissionsToSet + "\""); + return; + } + + File file = new File(filePath); + + if (permissionsToSet.contains("r") && !file.canRead()) { + Logger.logVerbose(LOG_TAG, "Setting missing read permissions for " + label + "file at path \"" + filePath + "\""); + file.setReadable(true); + } + + if (permissionsToSet.contains("w") && !file.canWrite()) { + Logger.logVerbose(LOG_TAG, "Setting missing write permissions for " + label + "file at path \"" + filePath + "\""); + file.setWritable(true); + } + + if (permissionsToSet.contains("x") && !file.canExecute()) { + Logger.logVerbose(LOG_TAG, "Setting missing execute permissions for " + label + "file at path \"" + filePath + "\""); + file.setExecutable(true); + } + } + + + + /** + * Checking missing permissions for file at path. + * + * @param context The {@link Context} to get error string. + * @param filePath The {@code path} for file to check permissions for. + * @param permissionsToCheck The 3 character string that contains the "r", "w", "x" or "-" in-order. + * @param ignoreIfNotExecutable The {@code boolean} that decides if missing executable permission + * error is to be ignored. + * @return Returns the {@code errmsg} if validating permissions failed, otherwise {@code null}. + */ + public static String checkMissingFilePermissions(@NonNull final Context context, final String filePath, final String permissionsToCheck, final boolean ignoreIfNotExecutable) { + return checkMissingFilePermissions(context, null, filePath, permissionsToCheck, ignoreIfNotExecutable); + } + + /** + * Checking missing permissions for file at path. + * + * @param context The {@link Context} to get error string. + * @param label The optional label for the file. This can optionally be {@code null}. + * @param filePath The {@code path} for file to check permissions for. + * @param permissionsToCheck The 3 character string that contains the "r", "w", "x" or "-" in-order. + * @param ignoreIfNotExecutable The {@code boolean} that decides if missing executable permission + * error is to be ignored. + * @return Returns the {@code errmsg} if validating permissions failed, otherwise {@code null}. + */ + public static String checkMissingFilePermissions(@NonNull final Context context, String label, final String filePath, final String permissionsToCheck, final boolean ignoreIfNotExecutable) { + label = (label == null ? "" : label + " "); + if (filePath == null || filePath.isEmpty()) return context.getString(R.string.error_null_or_empty_parameter, label + "file path", "checkMissingFilePermissions"); + + if (!isValidPermissingString(permissionsToCheck)) { + Logger.logError(LOG_TAG, "Invalid permissionsToCheck passed to checkMissingFilePermissions: \"" + permissionsToCheck + "\""); + return context.getString(R.string.error_invalid_file_permissions_string_to_check); + } + + File file = new File(filePath); + + // If file is not readable + if (permissionsToCheck.contains("r") && !file.canRead()) { + return context.getString(R.string.error_file_not_readable, label + "file"); + } + + // If file is not writable + if (permissionsToCheck.contains("w") && !file.canWrite()) { + return context.getString(R.string.error_file_not_writable, label + "file"); + } + // If file is not executable + // This canExecute() will give "avc: granted { execute }" warnings for target sdk 29 + else if (permissionsToCheck.contains("x") && !file.canExecute() && !ignoreIfNotExecutable) { + return context.getString(R.string.error_file_not_executable, label + "file"); + } + + return null; + } + + + + /** + * Checks whether string exactly matches the 3 character permission string that + * contains the "r", "w", "x" or "-" in-order. + * + * @param string The {@link String} to check. + * @return Returns {@code true} if string exactly matches a permission string, otherwise {@code false}. + */ + public static boolean isValidPermissingString(final String string) { + if (string == null || string.isEmpty()) return false; + return Pattern.compile("^([r-])[w-][x-]$", 0).matcher(string).matches(); + } + +} diff --git a/app/src/main/java/com/termux/app/file/filesystem/FileAttributes.java b/app/src/main/java/com/termux/app/file/filesystem/FileAttributes.java new file mode 100644 index 0000000000..03482136cd --- /dev/null +++ b/app/src/main/java/com/termux/app/file/filesystem/FileAttributes.java @@ -0,0 +1,416 @@ +/* + * Copyright (c) 2008, 2013, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code 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 + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package com.termux.app.file.filesystem; + +import android.os.Build; +import android.system.StructStat; + +import androidx.annotation.NonNull; + +import com.termux.app.utils.Logger; + +import java.io.File; +import java.io.FileDescriptor; +import java.io.IOException; +import java.util.concurrent.TimeUnit; +import java.util.Set; +import java.util.HashSet; + +/** + * Unix implementation of PosixFileAttributes. + * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r3:libcore/ojluni/src/main/java/sun/nio/fs/UnixFileAttributes.java + */ + +public class FileAttributes { + private String filePath; + private FileDescriptor fileDescriptor; + + private int st_mode; + private long st_ino; + private long st_dev; + private long st_rdev; + private long st_nlink; + private int st_uid; + private int st_gid; + private long st_size; + private long st_blksize; + private long st_blocks; + private long st_atime_sec; + private long st_atime_nsec; + private long st_mtime_sec; + private long st_mtime_nsec; + private long st_ctime_sec; + private long st_ctime_nsec; + + // created lazily + private volatile String owner; + private volatile String group; + private volatile FileKey key; + + private FileAttributes(String filePath) { + this.filePath = filePath; + } + + private FileAttributes(FileDescriptor fileDescriptor) { + this.fileDescriptor = fileDescriptor; + } + + // get the FileAttributes for a given file + public static FileAttributes get(String filePath, boolean followLinks) throws IOException { + FileAttributes fileAttributes; + + if (filePath == null || filePath.isEmpty()) + fileAttributes = new FileAttributes((String) null); + else + fileAttributes = new FileAttributes(new File(filePath).getAbsolutePath()); + + if (followLinks) { + NativeDispatcher.stat(filePath, fileAttributes); + } else { + NativeDispatcher.lstat(filePath, fileAttributes); + } + + // Logger.logDebug(fileAttributes.toString()); + + return fileAttributes; + } + + // get the FileAttributes for an open file + public static FileAttributes get(FileDescriptor fileDescriptor) throws IOException { + FileAttributes fileAttributes = new FileAttributes(fileDescriptor); + NativeDispatcher.fstat(fileDescriptor, fileAttributes); + return fileAttributes; + } + + public String file() { + if(filePath != null) + return filePath; + else if(fileDescriptor != null) + return fileDescriptor.toString(); + else + return null; + } + + // package-private + public boolean isSameFile(FileAttributes attrs) { + return ((st_ino == attrs.st_ino) && (st_dev == attrs.st_dev)); + } + + // package-private + public int mode() { + return st_mode; + } + + public long blksize() { + return st_blksize; + } + + public long blocks() { + return st_blocks; + } + + public long ino() { + return st_ino; + } + + public long dev() { + return st_dev; + } + + public long rdev() { + return st_rdev; + } + + public long nlink() { + return st_nlink; + } + + public int uid() { + return st_uid; + } + + public int gid() { + return st_gid; + } + + private static FileTime toFileTime(long sec, long nsec) { + if (nsec == 0) { + return FileTime.from(sec, TimeUnit.SECONDS); + } else { + // truncate to microseconds to avoid overflow with timestamps + // way out into the future. We can re-visit this if FileTime + // is updated to define a from(secs,nsecs) method. + long micro = sec * 1000000L + nsec / 1000L; + return FileTime.from(micro, TimeUnit.MICROSECONDS); + } + } + + public FileTime lastAccessTime() { + return toFileTime(st_atime_sec, st_atime_nsec); + } + + public FileTime lastModifiedTime() { + return toFileTime(st_mtime_sec, st_mtime_nsec); + } + + public FileTime lastChangeTime() { + return toFileTime(st_ctime_sec, st_ctime_nsec); + } + + public FileTime creationTime() { + return lastModifiedTime(); + } + + public boolean isRegularFile() { + return ((st_mode & UnixConstants.S_IFMT) == UnixConstants.S_IFREG); + } + + public boolean isDirectory() { + return ((st_mode & UnixConstants.S_IFMT) == UnixConstants.S_IFDIR); + } + + public boolean isSymbolicLink() { + return ((st_mode & UnixConstants.S_IFMT) == UnixConstants.S_IFLNK); + } + + public boolean isCharacter() { + return ((st_mode & UnixConstants.S_IFMT) == UnixConstants.S_IFCHR); + } + + public boolean isFifo() { + return ((st_mode & UnixConstants.S_IFMT) == UnixConstants.S_IFIFO); + } + + public boolean isBlock() { + return ((st_mode & UnixConstants.S_IFMT) == UnixConstants.S_IFBLK); + } + + public boolean isOther() { + int type = st_mode & UnixConstants.S_IFMT; + return (type != UnixConstants.S_IFREG && + type != UnixConstants.S_IFDIR && + type != UnixConstants.S_IFLNK); + } + + public boolean isDevice() { + int type = st_mode & UnixConstants.S_IFMT; + return (type == UnixConstants.S_IFCHR || + type == UnixConstants.S_IFBLK || + type == UnixConstants.S_IFIFO); + } + + public long size() { + return st_size; + } + + public FileKey fileKey() { + if (key == null) { + synchronized (this) { + if (key == null) { + key = new FileKey(st_dev, st_ino); + } + } + } + return key; + } + + public String owner() { + if (owner == null) { + synchronized (this) { + if (owner == null) { + owner = Integer.toString(st_uid); + } + } + } + return owner; + } + + public String group() { + if (group == null) { + synchronized (this) { + if (group == null) { + group = Integer.toString(st_gid); + } + } + } + return group; + } + + public Set permissions() { + int bits = (st_mode & UnixConstants.S_IAMB); + HashSet perms = new HashSet<>(); + + if ((bits & UnixConstants.S_IRUSR) > 0) + perms.add(FilePermission.OWNER_READ); + if ((bits & UnixConstants.S_IWUSR) > 0) + perms.add(FilePermission.OWNER_WRITE); + if ((bits & UnixConstants.S_IXUSR) > 0) + perms.add(FilePermission.OWNER_EXECUTE); + + if ((bits & UnixConstants.S_IRGRP) > 0) + perms.add(FilePermission.GROUP_READ); + if ((bits & UnixConstants.S_IWGRP) > 0) + perms.add(FilePermission.GROUP_WRITE); + if ((bits & UnixConstants.S_IXGRP) > 0) + perms.add(FilePermission.GROUP_EXECUTE); + + if ((bits & UnixConstants.S_IROTH) > 0) + perms.add(FilePermission.OTHERS_READ); + if ((bits & UnixConstants.S_IWOTH) > 0) + perms.add(FilePermission.OTHERS_WRITE); + if ((bits & UnixConstants.S_IXOTH) > 0) + perms.add(FilePermission.OTHERS_EXECUTE); + + return perms; + } + + public void loadFromStructStat(StructStat structStat) { + this.st_mode = structStat.st_mode; + this.st_ino = structStat.st_ino; + this.st_dev = structStat.st_dev; + this.st_rdev = structStat.st_rdev; + this.st_nlink = structStat.st_nlink; + this.st_uid = structStat.st_uid; + this.st_gid = structStat.st_gid; + this.st_size = structStat.st_size; + this.st_blksize = structStat.st_blksize; + this.st_blocks = structStat.st_blocks; + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { + this.st_atime_sec = structStat.st_atim.tv_sec; + this.st_atime_nsec = structStat.st_atim.tv_nsec; + this.st_mtime_sec = structStat.st_mtim.tv_sec; + this.st_mtime_nsec = structStat.st_mtim.tv_nsec; + this.st_ctime_sec = structStat.st_ctim.tv_sec; + this.st_ctime_nsec = structStat.st_ctim.tv_nsec; + } else { + this.st_atime_sec = structStat.st_atime; + this.st_atime_nsec = 0; + this.st_mtime_sec = structStat.st_mtime; + this.st_mtime_nsec = 0; + this.st_ctime_sec = structStat.st_ctime; + this.st_ctime_nsec = 0; + } + } + + public String getFileString() { + return "File: `" + file() + "`"; + } + + public String getTypeString() { + return "Type: `" + FileTypes.getFileType(this).getName() + "`"; + } + + public String getSizeString() { + return "Size: `" + size() + "`"; + } + + public String getBlocksString() { + return "Blocks: `" + blocks() + "`"; + } + + public String getIOBlockString() { + return "IO Block: `" + blksize() + "`"; + } + + public String getDeviceString() { + return "Device: `" + Long.toHexString(st_dev) + "`"; + } + + public String getInodeString() { + return "Inode: `" + st_ino + "`"; + } + + public String getLinksString() { + return "Links: `" + nlink() + "`"; + } + + public String getDeviceTypeString() { + return "Device Type: `" + rdev() + "`"; + } + + public String getOwnerString() { + return "Owner: `" + owner() + "`"; + } + + public String getGroupString() { + return "Group: `" + group() + "`"; + } + + public String getPermissionString() { + return "Permissions: `" + FilePermissions.toString(permissions()) + "`"; + } + + public String getAccessTimeString() { + return "Access Time: `" + lastAccessTime() + "`"; + } + + public String getModifiedTimeString() { + return "Modified Time: `" + lastModifiedTime() + "`"; + } + + public String getChangeTimeString() { + return "Change Time: `" + lastChangeTime() + "`"; + } + + @NonNull + @Override + public String toString() { + return getFileAttributesLogString(this); + } + + public static String getFileAttributesLogString(final FileAttributes fileAttributes) { + if (fileAttributes == null) return "null"; + + StringBuilder logString = new StringBuilder(); + + logString.append(fileAttributes.getFileString()); + + logString.append("\n").append(fileAttributes.getTypeString()); + + logString.append("\n").append(fileAttributes.getSizeString()); + logString.append("\n").append(fileAttributes.getBlocksString()); + logString.append("\n").append(fileAttributes.getIOBlockString()); + + logString.append("\n").append(fileAttributes.getDeviceString()); + logString.append("\n").append(fileAttributes.getInodeString()); + logString.append("\n").append(fileAttributes.getLinksString()); + + if(fileAttributes.isBlock() || fileAttributes.isCharacter()) + logString.append("\n").append(fileAttributes.getDeviceTypeString()); + + logString.append("\n").append(fileAttributes.getOwnerString()); + logString.append("\n").append(fileAttributes.getGroupString()); + logString.append("\n").append(fileAttributes.getPermissionString()); + + logString.append("\n").append(fileAttributes.getAccessTimeString()); + logString.append("\n").append(fileAttributes.getModifiedTimeString()); + logString.append("\n").append(fileAttributes.getChangeTimeString()); + + return logString.toString(); + } + +} diff --git a/app/src/main/java/com/termux/app/file/filesystem/FileKey.java b/app/src/main/java/com/termux/app/file/filesystem/FileKey.java new file mode 100644 index 0000000000..87908a2634 --- /dev/null +++ b/app/src/main/java/com/termux/app/file/filesystem/FileKey.java @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2008, 2009, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code 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 + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package com.termux.app.file.filesystem; + +/** + * Container for device/inode to uniquely identify file. + * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r3:libcore/ojluni/src/main/java/sun/nio/fs/UnixFileKey.java + */ + +public class FileKey { + private final long st_dev; + private final long st_ino; + + FileKey(long st_dev, long st_ino) { + this.st_dev = st_dev; + this.st_ino = st_ino; + } + + @Override + public int hashCode() { + return (int)(st_dev ^ (st_dev >>> 32)) + + (int)(st_ino ^ (st_ino >>> 32)); + } + + @Override + public boolean equals(Object obj) { + if (obj == this) + return true; + if (!(obj instanceof FileKey)) + return false; + FileKey other = (FileKey)obj; + return (this.st_dev == other.st_dev) && (this.st_ino == other.st_ino); + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append("(dev=") + .append(Long.toHexString(st_dev)) + .append(",ino=") + .append(st_ino) + .append(')'); + return sb.toString(); + } +} diff --git a/app/src/main/java/com/termux/app/file/filesystem/FilePermission.java b/app/src/main/java/com/termux/app/file/filesystem/FilePermission.java new file mode 100644 index 0000000000..6cb4dd1949 --- /dev/null +++ b/app/src/main/java/com/termux/app/file/filesystem/FilePermission.java @@ -0,0 +1,87 @@ + +/* + * Copyright (c) 2007, 2011, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code 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 + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package com.termux.app.file.filesystem; + +/** + * Defines the bits for use with the {@link FileAttributes#permissions() + * permissions} attribute. + * + *

The {@link FileAttributes} class defines methods for manipulating + * set of permissions. + * + * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r3:libcore/ojluni/src/main/java/java/nio/file/attribute/PosixFilePermission.java + * + * @since 1.7 + */ + +public enum FilePermission { + + /** + * Read permission, owner. + */ + OWNER_READ, + + /** + * Write permission, owner. + */ + OWNER_WRITE, + + /** + * Execute/search permission, owner. + */ + OWNER_EXECUTE, + + /** + * Read permission, group. + */ + GROUP_READ, + + /** + * Write permission, group. + */ + GROUP_WRITE, + + /** + * Execute/search permission, group. + */ + GROUP_EXECUTE, + + /** + * Read permission, others. + */ + OTHERS_READ, + + /** + * Write permission, others. + */ + OTHERS_WRITE, + + /** + * Execute/search permission, others. + */ + OTHERS_EXECUTE; +} diff --git a/app/src/main/java/com/termux/app/file/filesystem/FilePermissions.java b/app/src/main/java/com/termux/app/file/filesystem/FilePermissions.java new file mode 100644 index 0000000000..eb48579c70 --- /dev/null +++ b/app/src/main/java/com/termux/app/file/filesystem/FilePermissions.java @@ -0,0 +1,145 @@ +/* + * Copyright (c) 2007, 2011, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code 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 + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package com.termux.app.file.filesystem; + +import static com.termux.app.file.filesystem.FilePermission.*; + +import java.util.*; + +/** + * This class consists exclusively of static methods that operate on sets of + * {@link FilePermission} objects. + * + * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r3:libcore/ojluni/src/main/java/java/nio/file/attribute/PosixFilePermissions.java + * + * @since 1.7 + */ + +public final class FilePermissions { + private FilePermissions() { } + + // Write string representation of permission bits to {@code sb}. + private static void writeBits(StringBuilder sb, boolean r, boolean w, boolean x) { + if (r) { + sb.append('r'); + } else { + sb.append('-'); + } + if (w) { + sb.append('w'); + } else { + sb.append('-'); + } + if (x) { + sb.append('x'); + } else { + sb.append('-'); + } + } + + /** + * Returns the {@code String} representation of a set of permissions. It + * is guaranteed that the returned {@code String} can be parsed by the + * {@link #fromString} method. + * + *

If the set contains {@code null} or elements that are not of type + * {@code FilePermission} then these elements are ignored. + * + * @param perms + * the set of permissions + * + * @return the string representation of the permission set + */ + public static String toString(Set perms) { + StringBuilder sb = new StringBuilder(9); + writeBits(sb, perms.contains(OWNER_READ), perms.contains(OWNER_WRITE), + perms.contains(OWNER_EXECUTE)); + writeBits(sb, perms.contains(GROUP_READ), perms.contains(GROUP_WRITE), + perms.contains(GROUP_EXECUTE)); + writeBits(sb, perms.contains(OTHERS_READ), perms.contains(OTHERS_WRITE), + perms.contains(OTHERS_EXECUTE)); + return sb.toString(); + } + + private static boolean isSet(char c, char setValue) { + if (c == setValue) + return true; + if (c == '-') + return false; + throw new IllegalArgumentException("Invalid mode"); + } + private static boolean isR(char c) { return isSet(c, 'r'); } + private static boolean isW(char c) { return isSet(c, 'w'); } + private static boolean isX(char c) { return isSet(c, 'x'); } + + /** + * Returns the set of permissions corresponding to a given {@code String} + * representation. + * + *

The {@code perms} parameter is a {@code String} representing the + * permissions. It has 9 characters that are interpreted as three sets of + * three. The first set refers to the owner's permissions; the next to the + * group permissions and the last to others. Within each set, the first + * character is {@code 'r'} to indicate permission to read, the second + * character is {@code 'w'} to indicate permission to write, and the third + * character is {@code 'x'} for execute permission. Where a permission is + * not set then the corresponding character is set to {@code '-'}. + * + *

Usage Example: + * Suppose we require the set of permissions that indicate the owner has read, + * write, and execute permissions, the group has read and execute permissions + * and others have none. + *

+     *   Set<FilePermission> perms = FilePermissions.fromString("rwxr-x---");
+     * 
+ * + * @param perms + * string representing a set of permissions + * + * @return the resulting set of permissions + * + * @throws IllegalArgumentException + * if the string cannot be converted to a set of permissions + * + * @see #toString(Set) + */ + public static Set fromString(String perms) { + if (perms.length() != 9) + throw new IllegalArgumentException("Invalid mode"); + Set result = EnumSet.noneOf(FilePermission.class); + if (isR(perms.charAt(0))) result.add(OWNER_READ); + if (isW(perms.charAt(1))) result.add(OWNER_WRITE); + if (isX(perms.charAt(2))) result.add(OWNER_EXECUTE); + if (isR(perms.charAt(3))) result.add(GROUP_READ); + if (isW(perms.charAt(4))) result.add(GROUP_WRITE); + if (isX(perms.charAt(5))) result.add(GROUP_EXECUTE); + if (isR(perms.charAt(6))) result.add(OTHERS_READ); + if (isW(perms.charAt(7))) result.add(OTHERS_WRITE); + if (isX(perms.charAt(8))) result.add(OTHERS_EXECUTE); + return result; + } + +} diff --git a/app/src/main/java/com/termux/app/file/filesystem/FileTime.java b/app/src/main/java/com/termux/app/file/filesystem/FileTime.java new file mode 100644 index 0000000000..cf0f30fde9 --- /dev/null +++ b/app/src/main/java/com/termux/app/file/filesystem/FileTime.java @@ -0,0 +1,156 @@ +/* + * Copyright (c) 2009, 2013, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code 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 + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package com.termux.app.file.filesystem; + +import androidx.annotation.NonNull; + +import java.text.SimpleDateFormat; +import java.util.Calendar; +import java.util.Objects; +import java.util.concurrent.TimeUnit; + +/** + * Represents the value of a file's time stamp attribute. For example, it may + * represent the time that the file was last + * {@link FileAttributes#lastModifiedTime() modified}, + * {@link FileAttributes#lastAccessTime() accessed}, + * or {@link FileAttributes#creationTime() created}. + * + *

Instances of this class are immutable. + * + * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r3:libcore/ojluni/src/main/java/java/nio/file/attribute/FileTime.java + * + * @since 1.7 + * @see java.nio.file.Files#setLastModifiedTime + * @see java.nio.file.Files#getLastModifiedTime + */ + +public final class FileTime { + /** + * The unit of granularity to interpret the value. Null if + * this {@code FileTime} is converted from an {@code Instant}, + * the {@code value} and {@code unit} pair will not be used + * in this scenario. + */ + private final TimeUnit unit; + + /** + * The value since the epoch; can be negative. + */ + private final long value; + + + /** + * The value return by toString (created lazily) + */ + private String valueAsString; + + /** + * Initializes a new instance of this class. + */ + private FileTime(long value, TimeUnit unit) { + this.value = value; + this.unit = unit; + } + + /** + * Returns a {@code FileTime} representing a value at the given unit of + * granularity. + * + * @param value + * the value since the epoch (1970-01-01T00:00:00Z); can be + * negative + * @param unit + * the unit of granularity to interpret the value + * + * @return a {@code FileTime} representing the given value + */ + public static FileTime from(long value, @NonNull TimeUnit unit) { + Objects.requireNonNull(unit, "unit"); + return new FileTime(value, unit); + } + + /** + * Returns a {@code FileTime} representing the given value in milliseconds. + * + * @param value + * the value, in milliseconds, since the epoch + * (1970-01-01T00:00:00Z); can be negative + * + * @return a {@code FileTime} representing the given value + */ + public static FileTime fromMillis(long value) { + return new FileTime(value, TimeUnit.MILLISECONDS); + } + + /** + * Returns the value at the given unit of granularity. + * + *

Conversion from a coarser granularity that would numerically overflow + * saturate to {@code Long.MIN_VALUE} if negative or {@code Long.MAX_VALUE} + * if positive. + * + * @param unit + * the unit of granularity for the return value + * + * @return value in the given unit of granularity, since the epoch + * since the epoch (1970-01-01T00:00:00Z); can be negative + */ + public long to(TimeUnit unit) { + Objects.requireNonNull(unit, "unit"); + return unit.convert(this.value, this.unit); + } + + /** + * Returns the value in milliseconds. + * + *

Conversion from a coarser granularity that would numerically overflow + * saturate to {@code Long.MIN_VALUE} if negative or {@code Long.MAX_VALUE} + * if positive. + * + * @return the value in milliseconds, since the epoch (1970-01-01T00:00:00Z) + */ + public long toMillis() { + return unit.toMillis(value); + } + + @NonNull + @Override + public String toString() { + return getDate(toMillis(), "yyyy.MM.dd HH:mm:ss.SSS z"); + } + + public static String getDate(long milliSeconds, String format) { + try { + Calendar calendar = Calendar.getInstance(); + calendar.setTimeInMillis(milliSeconds); + return new SimpleDateFormat(format).format(calendar.getTime()); + } catch(Exception e) { + return Long.toString(milliSeconds); + } + } + +} diff --git a/app/src/main/java/com/termux/app/file/filesystem/FileType.java b/app/src/main/java/com/termux/app/file/filesystem/FileType.java new file mode 100644 index 0000000000..2378456b38 --- /dev/null +++ b/app/src/main/java/com/termux/app/file/filesystem/FileType.java @@ -0,0 +1,31 @@ +package com.termux.app.file.filesystem; + +/** The {@link Enum} that defines file types. */ +public enum FileType { + + NO_EXIST("no exist", 0), // 0000000 + REGULAR("regular", 1), // 0000001 + DIRECTORY("directory", 2), // 0000010 + SYMLINK("symlink", 4), // 0000100 + CHARACTER("character", 8), // 0001000 + FIFO("fifo", 16), // 0010000 + BLOCK("block", 32), // 0100000 + UNKNOWN("unknown", 64); // 1000000 + + private final String name; + private final int value; + + FileType(final String name, final int value) { + this.name = name; + this.value = value; + } + + public String getName() { + return name; + } + + public int getValue() { + return value; + } + +} diff --git a/app/src/main/java/com/termux/app/file/filesystem/FileTypes.java b/app/src/main/java/com/termux/app/file/filesystem/FileTypes.java new file mode 100644 index 0000000000..6f7507157e --- /dev/null +++ b/app/src/main/java/com/termux/app/file/filesystem/FileTypes.java @@ -0,0 +1,116 @@ +package com.termux.app.file.filesystem; + +import android.system.Os; + +import androidx.annotation.NonNull; + +import com.termux.app.utils.Logger; + +import java.io.File; + +public class FileTypes { + + /** Flags to represent regular, directory and symlink file types defined by {@link FileType} */ + public static final int FILE_TYPE_NORMAL_FLAGS = FileType.REGULAR.getValue() | FileType.DIRECTORY.getValue() | FileType.SYMLINK.getValue(); + + /** Flags to represent any file type defined by {@link FileType} */ + public static final int FILE_TYPE_ANY_FLAGS = Integer.MAX_VALUE; // 1111111111111111111111111111111 (31 1's) + + public static String convertFileTypeFlagsToNamesString(int fileTypeFlags) { + StringBuilder fileTypeFlagsStringBuilder = new StringBuilder(); + + FileType[] fileTypes = {FileType.REGULAR, FileType.DIRECTORY, FileType.SYMLINK, FileType.CHARACTER, FileType.FIFO, FileType.BLOCK, FileType.UNKNOWN}; + for (FileType fileType : fileTypes) { + if ((fileTypeFlags & fileType.getValue()) > 0) + fileTypeFlagsStringBuilder.append(fileType.getName()).append(","); + } + + String fileTypeFlagsString = fileTypeFlagsStringBuilder.toString(); + + if (fileTypeFlagsString.endsWith(",")) + fileTypeFlagsString = fileTypeFlagsString.substring(0, fileTypeFlagsString.lastIndexOf(",")); + + return fileTypeFlagsString; + } + + /** + * Checks the type of file that exists at {@code filePath}. + * + * Returns: + * - {@link FileType#NO_EXIST} if {@code filePath} is {@code null}, empty, an exception is raised + * or no file exists at {@code filePath}. + * - {@link FileType#REGULAR} if file at {@code filePath} is a regular file. + * - {@link FileType#DIRECTORY} if file at {@code filePath} is a directory file. + * - {@link FileType#SYMLINK} if file at {@code filePath} is a symlink file and {@code followLinks} is {@code false}. + * - {@link FileType#CHARACTER} if file at {@code filePath} is a character special file. + * - {@link FileType#FIFO} if file at {@code filePath} is a fifo special file. + * - {@link FileType#BLOCK} if file at {@code filePath} is a block special file. + * - {@link FileType#UNKNOWN} if file at {@code filePath} is of unknown type. + * + * The {@link File#isFile()} and {@link File#isDirectory()} uses {@link Os#stat(String)} system + * call (not {@link Os#lstat(String)}) to check file type and does follow symlinks. + * + * The {@link File#exists()} uses {@link Os#access(String, int)} system call to check if file is + * accessible and does not follow symlinks. However, it returns {@code false} for dangling symlinks, + * on android at least. Check https://stackoverflow.com/a/57747064/14686958 + * + * Basically {@link File} API is not reliable to check for symlinks. + * + * So we get the file type directly with {@link Os#lstat(String)} if {@code followLinks} is + * {@code false} and {@link Os#stat(String)} if {@code followLinks} is {@code true}. All exceptions + * are assumed as non-existence. + * + * The {@link org.apache.commons.io.FileUtils#isSymlink(File)} can also be used for checking + * symlinks but {@link FileAttributes} will provide access to more attributes if necessary, + * including getting other special file types considering that {@link File#exists()} can't be + * used to reliably check for non-existence and exclude the other 3 file types. commons.io is + * also not compatible with android < 8 for many things. + * + * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r3:libcore/ojluni/src/main/java/java/io/File.java;l=793 + * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r3:libcore/ojluni/src/main/java/java/io/UnixFileSystem.java;l=248 + * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r3:libcore/ojluni/src/main/native/UnixFileSystem_md.c;l=121 + * https://cs.android.com/android/_/android/platform/libcore/+/001ac51d61ad7443ba518bf2cf7e086efe698c6d + * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r3:libcore/luni/src/main/java/libcore/io/Os.java;l=51 + * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r3:libcore/luni/src/main/java/libcore/io/Libcore.java;l=45 + * https://cs.android.com/android/platform/superproject/+/master:frameworks/base/core/java/android/app/ActivityThread.java;l=7530 + * + * @param filePath The {@code path} for file to check. + * @param followLinks The {@code boolean} that decides if symlinks will be followed while + * finding type. If set to {@code true}, then type of symlink target will + * be returned if file at {@code filePath} is a symlink. If set to + * {@code false}, then type of file at {@code filePath} itself will be + * returned. + * @return Returns the {@link FileType} of file. + */ + public static FileType getFileType(final String filePath, final boolean followLinks) { + if (filePath == null || filePath.isEmpty()) return FileType.NO_EXIST; + + try { + FileAttributes fileAttributes = FileAttributes.get(filePath, followLinks); + return getFileType(fileAttributes); + } catch (Exception e) { + // If not a ENOENT (No such file or directory) exception + if(!e.getMessage().contains("ENOENT")) + Logger.logError("Failed to get file type for file at path \"" + filePath + "\": " + e.getMessage()); + return FileType.NO_EXIST; + } + } + + public static FileType getFileType(@NonNull final FileAttributes fileAttributes) { + if (fileAttributes.isRegularFile()) + return FileType.REGULAR; + else if (fileAttributes.isDirectory()) + return FileType.DIRECTORY; + else if (fileAttributes.isSymbolicLink()) + return FileType.SYMLINK; + else if (fileAttributes.isCharacter()) + return FileType.CHARACTER; + else if (fileAttributes.isFifo()) + return FileType.FIFO; + else if (fileAttributes.isBlock()) + return FileType.BLOCK; + else + return FileType.UNKNOWN; + } + +} diff --git a/app/src/main/java/com/termux/app/file/filesystem/NativeDispatcher.java b/app/src/main/java/com/termux/app/file/filesystem/NativeDispatcher.java new file mode 100644 index 0000000000..d4495373fd --- /dev/null +++ b/app/src/main/java/com/termux/app/file/filesystem/NativeDispatcher.java @@ -0,0 +1,58 @@ +package com.termux.app.file.filesystem; + +import android.system.ErrnoException; +import android.system.Os; + +import java.io.File; +import java.io.FileDescriptor; +import java.io.IOException; + +public class NativeDispatcher { + + public static void stat(String filePath, FileAttributes fileAttributes) throws IOException { + validateFileExistence(filePath); + + try { + fileAttributes.loadFromStructStat(Os.stat(filePath)); + } catch (ErrnoException e) { + throw new IOException("Failed to run Os.stat() on file at path \"" + filePath + "\": " + e.getMessage()); + } + } + + public static void lstat(String filePath, FileAttributes fileAttributes) throws IOException { + validateFileExistence(filePath); + + try { + fileAttributes.loadFromStructStat(Os.lstat(filePath)); + } catch (ErrnoException e) { + throw new IOException("Failed to run Os.lstat() on file at path \"" + filePath + "\": " + e.getMessage()); + } + } + + public static void fstat(FileDescriptor fileDescriptor, FileAttributes fileAttributes) throws IOException { + validateFileDescriptor(fileDescriptor); + + try { + fileAttributes.loadFromStructStat(Os.fstat(fileDescriptor)); + } catch (ErrnoException e) { + throw new IOException("Failed to run Os.fstat() on file descriptor \"" + fileDescriptor.toString() + "\": " + e.getMessage()); + } + } + + public static void validateFileExistence(String filePath) throws IOException { + if (filePath == null || filePath.isEmpty()) throw new IOException("The path is null or empty"); + + File file = new File(filePath); + + //if(!file.exists()) + // throw new IOException("No such file or directory: \"" + filePath + "\""); + } + + public static void validateFileDescriptor(FileDescriptor fileDescriptor) throws IOException { + if (fileDescriptor == null) throw new IOException("The file descriptor is null"); + + if(!fileDescriptor.valid()) + throw new IOException("No such file descriptor: \"" + fileDescriptor.toString() + "\""); + } + +} diff --git a/app/src/main/java/com/termux/app/file/filesystem/UnixConstants.java b/app/src/main/java/com/termux/app/file/filesystem/UnixConstants.java new file mode 100644 index 0000000000..0cc7a79dae --- /dev/null +++ b/app/src/main/java/com/termux/app/file/filesystem/UnixConstants.java @@ -0,0 +1,149 @@ + +/* + * Copyright (c) 2008, 2009, Oracle and/or its affiliates. All rights reserved. + * + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code 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 + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + * + */ +// AUTOMATICALLY GENERATED FILE - DO NOT EDIT +package com.termux.app.file.filesystem; + +// BEGIN Android-changed: Use constants from android.system.OsConstants. http://b/32203242 +// Those constants are initialized by native code to ensure correctness on different architectures. +// AT_SYMLINK_NOFOLLOW (used by fstatat) and AT_REMOVEDIR (used by unlinkat) as of July 2018 do not +// have equivalents in android.system.OsConstants so left unchanged. +import android.system.OsConstants; + +/** + * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r3:libcore/ojluni/src/main/java/sun/nio/fs/UnixConstants.java + */ +public class UnixConstants { + private UnixConstants() { } + + static final int O_RDONLY = OsConstants.O_RDONLY; + + static final int O_WRONLY = OsConstants.O_WRONLY; + + static final int O_RDWR = OsConstants.O_RDWR; + + static final int O_APPEND = OsConstants.O_APPEND; + + static final int O_CREAT = OsConstants.O_CREAT; + + static final int O_EXCL = OsConstants.O_EXCL; + + static final int O_TRUNC = OsConstants.O_TRUNC; + + static final int O_SYNC = OsConstants.O_SYNC; + + static final int O_DSYNC = OsConstants.O_DSYNC; + + static final int O_NOFOLLOW = OsConstants.O_NOFOLLOW; + + static final int S_IAMB = get_S_IAMB(); + + static final int S_IRUSR = OsConstants.S_IRUSR; + + static final int S_IWUSR = OsConstants.S_IWUSR; + + static final int S_IXUSR = OsConstants.S_IXUSR; + + static final int S_IRGRP = OsConstants.S_IRGRP; + + static final int S_IWGRP = OsConstants.S_IWGRP; + + static final int S_IXGRP = OsConstants.S_IXGRP; + + static final int S_IROTH = OsConstants.S_IROTH; + + static final int S_IWOTH = OsConstants.S_IWOTH; + + static final int S_IXOTH = OsConstants.S_IXOTH; + + static final int S_IFMT = OsConstants.S_IFMT; + + static final int S_IFREG = OsConstants.S_IFREG; + + static final int S_IFDIR = OsConstants.S_IFDIR; + + static final int S_IFLNK = OsConstants.S_IFLNK; + + static final int S_IFCHR = OsConstants.S_IFCHR; + + static final int S_IFBLK = OsConstants.S_IFBLK; + + static final int S_IFIFO = OsConstants.S_IFIFO; + + static final int R_OK = OsConstants.R_OK; + + static final int W_OK = OsConstants.W_OK; + + static final int X_OK = OsConstants.X_OK; + + static final int F_OK = OsConstants.F_OK; + + static final int ENOENT = OsConstants.ENOENT; + + static final int EACCES = OsConstants.EACCES; + + static final int EEXIST = OsConstants.EEXIST; + + static final int ENOTDIR = OsConstants.ENOTDIR; + + static final int EINVAL = OsConstants.EINVAL; + + static final int EXDEV = OsConstants.EXDEV; + + static final int EISDIR = OsConstants.EISDIR; + + static final int ENOTEMPTY = OsConstants.ENOTEMPTY; + + static final int ENOSPC = OsConstants.ENOSPC; + + static final int EAGAIN = OsConstants.EAGAIN; + + static final int ENOSYS = OsConstants.ENOSYS; + + static final int ELOOP = OsConstants.ELOOP; + + static final int EROFS = OsConstants.EROFS; + + static final int ENODATA = OsConstants.ENODATA; + + static final int ERANGE = OsConstants.ERANGE; + + static final int EMFILE = OsConstants.EMFILE; + + // S_IAMB are access mode bits, therefore, calculated by taking OR of all the read, write and + // execute permissions bits for owner, group and other. + private static int get_S_IAMB() { + return (OsConstants.S_IRUSR | OsConstants.S_IWUSR | OsConstants.S_IXUSR | + OsConstants.S_IRGRP | OsConstants.S_IWGRP | OsConstants.S_IXGRP | + OsConstants.S_IROTH | OsConstants.S_IWOTH | OsConstants.S_IXOTH); + } + // END Android-changed: Use constants from android.system.OsConstants. http://b/32203242 + + + static final int AT_SYMLINK_NOFOLLOW = 0x100; + static final int AT_REMOVEDIR = 0x200; +} diff --git a/app/src/main/java/com/termux/app/file/tests/FileUtilsTests.java b/app/src/main/java/com/termux/app/file/tests/FileUtilsTests.java new file mode 100644 index 0000000000..21db1d57db --- /dev/null +++ b/app/src/main/java/com/termux/app/file/tests/FileUtilsTests.java @@ -0,0 +1,301 @@ +package com.termux.app.file.tests; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import com.termux.app.TermuxConstants; +import com.termux.app.file.FileUtils; +import com.termux.app.utils.Logger; + +import java.io.File; +import java.nio.charset.Charset; + +public class FileUtilsTests { + + private static final String LOG_TAG = "FileUtilsTests"; + + /** + * Run basic tests for {@link FileUtils} class. + * + * Move tests need to be written, specially for failures. + * + * The log level must be set to verbose. + * + * Run at app startup like in an activity + * FileUtilsTests.runTests(this, TermuxConstants.TERMUX_HOME_DIR_PATH + "/FileUtilsTests"); + * + * @param context The {@link Context} for operations. + */ + public static void runTests(@NonNull final Context context, @NonNull final String testRootDirectoryPath) { + try { + Logger.logInfo(LOG_TAG, "Running tests"); + Logger.logInfo(LOG_TAG, "testRootDirectoryPath: \"" + testRootDirectoryPath + "\""); + + String fileUtilsTestsDirectoryCanonicalPath = FileUtils.getCanonicalPath(testRootDirectoryPath, null, false); + assertEqual("FileUtilsTests directory path is not a canonical path", testRootDirectoryPath, fileUtilsTestsDirectoryCanonicalPath); + + runTestsInner(context, testRootDirectoryPath); + Logger.logInfo(LOG_TAG, "All tests successful"); + } catch (Exception e) { + Logger.logErrorAndShowToast(context, LOG_TAG, e.getMessage()); + } + } + + private static void runTestsInner(@NonNull final Context context, @NonNull final String testRootDirectoryPath) throws Exception { + String errmsg; + String label; + String path; + + /* + * - dir1 + * - sub_dir1 + * - sub_reg1 + * - sub_sym1 (absolute symlink to dir2) + * - sub_sym2 (copy of sub_sym1 for symlink to dir2) + * - sub_sym3 (relative symlink to dir4) + * - dir2 + * - sub_reg1 + * - sub_reg2 (copy of dir2/sub_reg1) + * - dir3 (copy of dir1) + * - dir4 (moved from dir3) + */ + + String dir1_label = "dir1"; + String dir1_path = testRootDirectoryPath + "/dir1"; + + String dir1__sub_dir1_label = "dir1/sub_dir1"; + String dir1__sub_dir1_path = dir1_path + "/sub_dir1"; + + String dir1__sub_reg1_label = "dir1/sub_reg1"; + String dir1__sub_reg1_path = dir1_path + "/sub_reg1"; + + String dir1__sub_sym1_label = "dir1/sub_sym1"; + String dir1__sub_sym1_path = dir1_path + "/sub_sym1"; + + String dir1__sub_sym2_label = "dir1/sub_sym2"; + String dir1__sub_sym2_path = dir1_path + "/sub_sym2"; + + String dir1__sub_sym3_label = "dir1/sub_sym3"; + String dir1__sub_sym3_path = dir1_path + "/sub_sym3"; + + + String dir2_label = "dir2"; + String dir2_path = testRootDirectoryPath + "/dir2"; + + String dir2__sub_reg1_label = "dir2/sub_reg1"; + String dir2__sub_reg1_path = dir2_path + "/sub_reg1"; + + String dir2__sub_reg2_label = "dir2/sub_reg2"; + String dir2__sub_reg2_path = dir2_path + "/sub_reg2"; + + + String dir3_label = "dir3"; + String dir3_path = testRootDirectoryPath + "/dir3"; + + String dir4_label = "dir4"; + String dir4_path = testRootDirectoryPath + "/dir4"; + + + + + + // Create or clear test root directory file + label = "testRootDirectoryPath"; + errmsg = FileUtils.clearDirectory(context, label, testRootDirectoryPath); + assertEqual("Failed to create " + label + " directory file", null, errmsg); + + if(!FileUtils.directoryFileExists(testRootDirectoryPath, false)) + throwException("The " + label + " directory file does not exist as expected after creation"); + + + // Create dir1 directory file + errmsg = FileUtils.createDirectoryFile(context, dir1_label, dir1_path); + assertEqual("Failed to create " + dir1_label + " directory file", null, errmsg); + + // Create dir2 directory file + errmsg = FileUtils.createDirectoryFile(context, dir2_label, dir2_path); + assertEqual("Failed to create " + dir2_label + " directory file", null, errmsg); + + + + + + // Create dir1/sub_dir1 directory file + label = dir1__sub_dir1_label; path = dir1__sub_dir1_path; + errmsg = FileUtils.createDirectoryFile(context, label, path); + assertEqual("Failed to create " + label + " directory file", null, errmsg); + if(!FileUtils.directoryFileExists(path, false)) + throwException("The " + label + " directory file does not exist as expected after creation"); + + // Create dir1/sub_reg1 regular file + label = dir1__sub_reg1_label; path = dir1__sub_reg1_path; + errmsg = FileUtils.createRegularFile(context, label, path); + assertEqual("Failed to create " + label + " regular file", null, errmsg); + if(!FileUtils.regularFileExists(path, false)) + throwException("The " + label + " regular file does not exist as expected after creation"); + + // Create dir1/sub_sym1 -> dir2 absolute symlink file + label = dir1__sub_sym1_label; path = dir1__sub_sym1_path; + errmsg = FileUtils.createSymlinkFile(context, label, dir2_path, path); + assertEqual("Failed to create " + label + " symlink file", null, errmsg); + if(!FileUtils.symlinkFileExists(path)) + throwException("The " + label + " symlink file does not exist as expected after creation"); + + // Copy dir1/sub_sym1 symlink file to dir1/sub_sym2 + label = dir1__sub_sym2_label; path = dir1__sub_sym2_path; + errmsg = FileUtils.copySymlinkFile(context, label, dir1__sub_sym1_path, path, false); + assertEqual("Failed to copy " + dir1__sub_sym1_label + " symlink file to " + label, null, errmsg); + if(!FileUtils.symlinkFileExists(path)) + throwException("The " + label + " symlink file does not exist as expected after copying it from " + dir1__sub_sym1_label); + if(!new File(path).getCanonicalPath().equals(dir2_path)) + throwException("The " + label + " symlink file does not point to " + dir2_label); + + + + + + // Write "line1" to dir2/sub_reg1 regular file + label = dir2__sub_reg1_label; path = dir2__sub_reg1_path; + errmsg = FileUtils.writeStringToFile(context, label, path, Charset.defaultCharset(), "line1", false); + assertEqual("Failed to write string to " + label + " file with append mode false", null, errmsg); + if(!FileUtils.regularFileExists(path, false)) + throwException("The " + label + " file does not exist as expected after writing to it with append mode false"); + + // Write "line2" to dir2/sub_reg1 regular file + errmsg = FileUtils.writeStringToFile(context, label, path, Charset.defaultCharset(), "\nline2", true); + assertEqual("Failed to write string to " + label + " file with append mode true", null, errmsg); + + // Read dir2/sub_reg1 regular file + StringBuilder dataStringBuilder = new StringBuilder(); + errmsg = FileUtils.readStringFromFile(context, label, path, Charset.defaultCharset(), dataStringBuilder, false); + assertEqual("Failed to read from " + label + " file", null, errmsg); + assertEqual("The data read from " + label + " file in not as expected", "line1\nline2", dataStringBuilder.toString()); + + // Copy dir2/sub_reg1 regular file to dir2/sub_reg2 file + label = dir2__sub_reg2_label; path = dir2__sub_reg2_path; + errmsg = FileUtils.copyRegularFile(context, label, dir2__sub_reg1_path, path, false); + assertEqual("Failed to copy " + dir2__sub_reg1_label + " regular file to " + label, null, errmsg); + if(!FileUtils.regularFileExists(path, false)) + throwException("The " + label + " regular file does not exist as expected after copying it from " + dir2__sub_reg1_label); + + + + + + // Copy dir1 directory file to dir3 + label = dir3_label; path = dir3_path; + errmsg = FileUtils.copyDirectoryFile(context, label, dir2_path, path, false); + assertEqual("Failed to copy " + dir2_label + " directory file to " + label, null, errmsg); + if(!FileUtils.directoryFileExists(path, false)) + throwException("The " + label + " directory file does not exist as expected after copying it from " + dir2_label); + + // Copy dir1 directory file to dir3 again to test overwrite + label = dir3_label; path = dir3_path; + errmsg = FileUtils.copyDirectoryFile(context, label, dir2_path, path, false); + assertEqual("Failed to copy " + dir2_label + " directory file to " + label, null, errmsg); + if(!FileUtils.directoryFileExists(path, false)) + throwException("The " + label + " directory file does not exist as expected after copying it from " + dir2_label); + + // Move dir3 directory file to dir4 + label = dir4_label; path = dir4_path; + errmsg = FileUtils.moveDirectoryFile(context, label, dir3_path, path, false); + assertEqual("Failed to move " + dir3_label + " directory file to " + label, null, errmsg); + if(!FileUtils.directoryFileExists(path, false)) + throwException("The " + label + " directory file does not exist as expected after copying it from " + dir3_label); + + + + + + // Create dir1/sub_sym3 -> dir4 relative symlink file + label = dir1__sub_sym3_label; path = dir1__sub_sym3_path; + errmsg = FileUtils.createSymlinkFile(context, label, "../dir4", path); + assertEqual("Failed to create " + label + " symlink file", null, errmsg); + if(!FileUtils.symlinkFileExists(path)) + throwException("The " + label + " symlink file does not exist as expected after creation"); + + // Create dir1/sub_sym3 -> dirX relative dangling symlink file + // This is to ensure that symlinkFileExists returns true if a symlink file exists but is dangling + label = dir1__sub_sym3_label; path = dir1__sub_sym3_path; + errmsg = FileUtils.createSymlinkFile(context, label, "../dirX", path); + assertEqual("Failed to create " + label + " symlink file", null, errmsg); + if(!FileUtils.symlinkFileExists(path)) + throwException("The " + label + " dangling symlink file does not exist as expected after creation"); + + + + + + // Delete dir1/sub_sym2 symlink file + label = dir1__sub_sym2_label; path = dir1__sub_sym2_path; + errmsg = FileUtils.deleteSymlinkFile(context, label, path, false); + assertEqual("Failed to delete " + label + " symlink file", null, errmsg); + if(FileUtils.fileExists(path, false)) + throwException("The " + label + " symlink file still exist after deletion"); + + // Check if dir2 directory file still exists after deletion of dir1/sub_sym2 since it was a symlink to dir2 + // When deleting a symlink file, its target must not be deleted + label = dir2_label; path = dir2_path; + if(!FileUtils.directoryFileExists(path, false)) + throwException("The " + label + " directory file has unexpectedly been deleted after deletion of " + dir1__sub_sym2_label); + + + + + + // Delete dir1 directory file + label = dir1_label; path = dir1_path; + errmsg = FileUtils.deleteDirectoryFile(context, label, path, false); + assertEqual("Failed to delete " + label + " directory file", null, errmsg); + if(FileUtils.fileExists(path, false)) + throwException("The " + label + " directory file still exist after deletion"); + + + // Check if dir2 directory file and dir2/sub_reg1 regular file still exist after deletion of + // dir1 since there was a dir1/sub_sym1 symlink to dir2 in it + // When deleting a directory, any targets of symlinks must not be deleted when deleting symlink files + label = dir2_label; path = dir2_path; + if(!FileUtils.directoryFileExists(path, false)) + throwException("The " + label + " directory file has unexpectedly been deleted after deletion of " + dir1_label); + label = dir2__sub_reg1_label; path = dir2__sub_reg1_path; + if(!FileUtils.fileExists(path, false)) + throwException("The " + label + " regular file has unexpectedly been deleted after deletion of " + dir1_label); + + + + + + // Delete dir2/sub_reg1 regular file + label = dir2__sub_reg1_label; path = dir2__sub_reg1_path; + errmsg = FileUtils.deleteRegularFile(context, label, path, false); + assertEqual("Failed to delete " + label + " regular file", null, errmsg); + if(FileUtils.fileExists(path, false)) + throwException("The " + label + " regular file still exist after deletion"); + + FileUtils.getFileType("/dev/ptmx", false); + FileUtils.getFileType("/dev/null", false); + } + + public static void assertEqual(@NonNull final String message, final String expected, final String actual) throws Exception { + if (!equalsRegardingNull(expected, actual)) + throwException(message + "\nexpected: \"" + expected + "\"\nactual: \"" + actual + "\""); + } + + private static boolean equalsRegardingNull(final String expected, final String actual) { + if (expected == null) { + return actual == null; + } + + return isEquals(expected, actual); + } + + private static boolean isEquals(String expected, String actual) { + return expected.equals(actual); + } + + public static void throwException(@NonNull final String message) throws Exception { + throw new Exception(message); + } + +} diff --git a/app/src/main/java/com/termux/app/utils/FileUtils.java b/app/src/main/java/com/termux/app/utils/FileUtils.java deleted file mode 100644 index 194e37f920..0000000000 --- a/app/src/main/java/com/termux/app/utils/FileUtils.java +++ /dev/null @@ -1,376 +0,0 @@ -package com.termux.app.utils; - -import android.content.Context; - -import com.termux.R; -import com.termux.app.TermuxConstants; - -import java.io.File; -import java.util.regex.Pattern; - -public class FileUtils { - - private static final String LOG_TAG = "FileUtils"; - - /** - * Replace "$PREFIX/" or "~/" prefix with termux absolute paths. - * - * @param path The {@code path} to expand. - * @return Returns the {@code expand path}. - */ - public static String getExpandedTermuxPath(String path) { - if(path != null && !path.isEmpty()) { - path = path.replaceAll("^\\$PREFIX$", TermuxConstants.TERMUX_PREFIX_DIR_PATH); - path = path.replaceAll("^\\$PREFIX/", TermuxConstants.TERMUX_PREFIX_DIR_PATH + "/"); - path = path.replaceAll("^~/$", TermuxConstants.TERMUX_HOME_DIR_PATH); - path = path.replaceAll("^~/", TermuxConstants.TERMUX_HOME_DIR_PATH + "/"); - } - - return path; - } - - /** - * Replace termux absolute paths with "$PREFIX/" or "~/" prefix. - * - * @param path The {@code path} to unexpand. - * @return Returns the {@code unexpand path}. - */ - public static String getUnExpandedTermuxPath(String path) { - if(path != null && !path.isEmpty()) { - path = path.replaceAll("^" + Pattern.quote(TermuxConstants.TERMUX_PREFIX_DIR_PATH) + "/", "\\$PREFIX/"); - path = path.replaceAll("^" + Pattern.quote(TermuxConstants.TERMUX_HOME_DIR_PATH) + "/", "~/"); - } - - return path; - } - - /** - * If {@code expandPath} is enabled, then input path is first attempted to be expanded by calling - * {@link #getExpandedTermuxPath(String)}. - * - * Then if path is already an absolute path, then it is used as is to get canonical path. - * If path is not an absolute path and {code prefixForNonAbsolutePath} is not {@code null}, then - * {code prefixForNonAbsolutePath} + "/" is prefixed before path before getting canonical path. - * If path is not an absolute path and {code prefixForNonAbsolutePath} is {@code null}, then - * "/" is prefixed before path before getting canonical path. - * - * If an exception is raised to get the canonical path, then absolute path is returned. - * - * @param path The {@code path} to convert. - * @param prefixForNonAbsolutePath Optional prefix path to prefix before non-absolute paths. This - * can be set to {@code null} if non-absolute paths should - * be prefixed with "/". The call to {@link File#getCanonicalPath()} - * will automatically do this anyways. - * @return Returns the {@code canonical path}. - */ - public static String getCanonicalPath(String path, String prefixForNonAbsolutePath, boolean expandPath) { - if (path == null) path = ""; - - if(expandPath) - path = getExpandedTermuxPath(path); - - String absolutePath; - - // If path is already an absolute path - if (path.startsWith("/") ) { - absolutePath = path; - } else { - if (prefixForNonAbsolutePath != null) - absolutePath = prefixForNonAbsolutePath + "/" + path; - else - absolutePath = "/" + path; - } - - try { - return new File(absolutePath).getCanonicalPath(); - } catch(Exception e) { - } - - return absolutePath; - } - - /** - * Removes one or more forward slashes "//" with single slash "/" - * Removes "./" - * Removes trailing forward slash "/" - * - * @param path The {@code path} to convert. - * @return Returns the {@code normalized path}. - */ - public static String normalizePath(String path) { - if (path == null) return null; - - path = path.replaceAll("/+", "/"); - path = path.replaceAll("\\./", ""); - - if (path.endsWith("/")) { - path = path.substring(0, path.length() - 1); - } - - return path; - } - - /** - * Determines whether path is in {@code dirPath}. - * - * @param path The {@code path} to check. - * @param dirPath The {@code directory path} to check in. - * @param ensureUnder If set to {@code true}, then it will be ensured that {@code path} is - * under the directory and does not equal it. - * @return Returns {@code true} if path in {@code dirPath}, otherwise returns {@code false}. - */ - public static boolean isPathInDirPath(String path, String dirPath, boolean ensureUnder) { - if (path == null || dirPath == null) return false; - - try { - path = new File(path).getCanonicalPath(); - } catch(Exception e) { - return false; - } - - String normalizedDirPath = normalizePath(dirPath); - - if(ensureUnder) - return !path.equals(normalizedDirPath) && path.startsWith(normalizedDirPath + "/"); - else - return path.startsWith(normalizedDirPath + "/"); - } - - /** - * Validate the existence and permissions of regular file at path. - * - * If the {@code parentDirPath} is not {@code null}, then setting of missing permissions will - * only be done if {@code path} is under {@code parentDirPath}. - * - * @param context The {@link Context} to get error string. - * @param path The {@code path} for file to validate. - * @param parentDirPath The optional {@code parent directory path} to restrict operations to. - * This can optionally be {@code null}. - * @param permissionsToCheck The 3 character string that contains the "r", "w", "x" or "-" in-order. - * @param setMissingPermissions The {@code boolean} that decides if missing permissions are to be - * automatically set. - * @param ignoreErrorsIfPathIsUnderParentDirPath The {@code boolean} that decides if permission - * errors are to be ignored if path is under - * {@code parentDirPath}. - * @return Returns the {@code errmsg} if path is not a regular file, or validating permissions - * failed, otherwise {@code null}. - */ - public static String validateRegularFileExistenceAndPermissions(final Context context, final String path, final String parentDirPath, String permissionsToCheck, final boolean setMissingPermissions, final boolean ignoreErrorsIfPathIsUnderParentDirPath) { - if (path == null || path.isEmpty()) return context.getString(R.string.error_null_or_empty_file); - - try { - File file = new File(path); - - // If file exits but not a regular file - if (file.exists() && !file.isFile()) { - return context.getString(R.string.error_non_regular_file_found); - } - - boolean isPathUnderParentDirPath = false; - if (parentDirPath != null) { - // The path can only be under parent directory path - isPathUnderParentDirPath = isPathInDirPath(path, parentDirPath, true); - } - - // If setMissingPermissions is enabled and path is a regular file - if (setMissingPermissions && permissionsToCheck != null && file.isFile()) { - // If there is not parentDirPath restriction or path is under parentDirPath - if (parentDirPath == null || (isPathUnderParentDirPath && new File(parentDirPath).isDirectory())) { - setMissingFilePermissions(path, permissionsToCheck); - } - } - - // If path is not a regular file - // Regular files cannot be automatically created so we do not ignore if missing - if (!file.isFile()) { - return context.getString(R.string.error_no_regular_file_found); - } - - // If there is not parentDirPath restriction or path is not under parentDirPath or - // if permission errors must not be ignored for paths under parentDirPath - if (parentDirPath == null || !isPathUnderParentDirPath || !ignoreErrorsIfPathIsUnderParentDirPath) { - if (permissionsToCheck != null) { - // Check if permissions are missing - return checkMissingFilePermissions(context, path, permissionsToCheck, "File", false); - } - } - } - // Some function calls may throw SecurityException, etc - catch (Exception e) { - return context.getString(R.string.error_validate_file_existence_and_permissions_failed_with_exception, path, e.getMessage()); - } - - return null; - - } - - /** - * Validate the existence and permissions of directory at path. - * - * If the {@code parentDirPath} is not {@code null}, then creation of missing directory and - * setting of missing permissions will only be done if {@code path} is under - * {@code parentDirPath} or equals {@code parentDirPath}. - * - * @param context The {@link Context} to get error string. - * @param path The {@code path} for file to validate. - * @param parentDirPath The optional {@code parent directory path} to restrict operations to. - * This can optionally be {@code null}. - * @param permissionsToCheck The 3 character string that contains the "r", "w", "x" or "-" in-order. - * @param createDirectoryIfMissing The {@code boolean} that decides if directory - * should be created if its missing. - * @param setMissingPermissions The {@code boolean} that decides if missing permissions are to be - * automatically set. - * @param ignoreErrorsIfPathIsInParentDirPath The {@code boolean} that decides if existence - * and permission errors are to be ignored if path is - * in {@code parentDirPath}. - * @param ignoreIfNotExecutable The {@code boolean} that decides if missing executable permission - * error is to be ignored. This allows making an attempt to set - * executable permissions, but ignoring if it fails. - * @return Returns the {@code errmsg} if path is not a directory, or validating permissions - * failed, otherwise {@code null}. - */ - public static String validateDirectoryExistenceAndPermissions(final Context context, final String path, final String parentDirPath, String permissionsToCheck, final boolean createDirectoryIfMissing, final boolean setMissingPermissions, final boolean ignoreErrorsIfPathIsInParentDirPath, final boolean ignoreIfNotExecutable) { - if (path == null || path.isEmpty()) return context.getString(R.string.error_null_or_empty_directory); - - try { - File file = new File(path); - - // If file exits but not a directory file - if (file.exists() && !file.isDirectory()) { - return context.getString(R.string.error_non_directory_file_found); - } - - boolean isPathInParentDirPath = false; - if (parentDirPath != null) { - // The path can be equal to parent directory path or under it - isPathInParentDirPath = isPathInDirPath(path, parentDirPath, false); - } - - if (createDirectoryIfMissing || setMissingPermissions) { - // If there is not parentDirPath restriction or path is in parentDirPath - if (parentDirPath == null || (isPathInParentDirPath && new File(parentDirPath).isDirectory())) { - // If createDirectoryIfMissing is enabled and no file exists at path, then create directory - if (createDirectoryIfMissing && !file.exists()) { - Logger.logVerbose(LOG_TAG, "Creating missing directory at path: \"" + path + "\""); - // If failed to create directory - if (!file.mkdirs()) { - return context.getString(R.string.error_creating_missing_directory_failed, path); - } - } - - // If setMissingPermissions is enabled and path is a directory - if (setMissingPermissions && permissionsToCheck != null && file.isDirectory()) { - setMissingFilePermissions(path, permissionsToCheck); - } - } - } - - // If there is not parentDirPath restriction or path is not in parentDirPath or - // if existence or permission errors must not be ignored for paths in parentDirPath - if (parentDirPath == null || !isPathInParentDirPath || !ignoreErrorsIfPathIsInParentDirPath) { - // If path is not a directory - // Directories can be automatically created so we can ignore if missing with above check - if (!file.isDirectory()) { - return context.getString(R.string.error_no_directory_found); - } - - if (permissionsToCheck != null) { - // Check if permissions are missing - return checkMissingFilePermissions(context, path, permissionsToCheck, "Directory", ignoreIfNotExecutable); - } - } - } - // Some function calls may throw SecurityException, etc - catch (Exception e) { - return context.getString(R.string.error_validate_directory_existence_and_permissions_failed_with_exception, path, e.getMessage()); - } - - return null; - } - - /** - * Set missing permissions for file at path. - * - * @param path The {@code path} for file to set permissions to. - * @param permissionsToSet The 3 character string that contains the "r", "w", "x" or "-" in-order. - */ - public static void setMissingFilePermissions(String path, String permissionsToSet) { - if (path == null || path.isEmpty()) return; - - if (!isValidPermissingString(permissionsToSet)) { - Logger.logError(LOG_TAG, "Invalid permissionsToSet passed to setMissingFilePermissions: \"" + permissionsToSet + "\""); - return; - } - - File file = new File(path); - - if (permissionsToSet.contains("r") && !file.canRead()) { - Logger.logVerbose(LOG_TAG, "Setting missing read permissions for file at path: \"" + path + "\""); - file.setReadable(true); - } - - if (permissionsToSet.contains("w") && !file.canWrite()) { - Logger.logVerbose(LOG_TAG, "Setting missing write permissions for file at path: \"" + path + "\""); - file.setWritable(true); - } - - if (permissionsToSet.contains("x") && !file.canExecute()) { - Logger.logVerbose(LOG_TAG, "Setting missing execute permissions for file at path: \"" + path + "\""); - file.setExecutable(true); - } - } - - /** - * Checking missing permissions for file at path. - * - * @param context The {@link Context} to get error string. - * @param path The {@code path} for file to check permissions for. - * @param permissionsToCheck The 3 character string that contains the "r", "w", "x" or "-" in-order. - * @param fileType The label for the type of file to use for error string. - * @param ignoreIfNotExecutable The {@code boolean} that decides if missing executable permission - * error is to be ignored. - * @return Returns the {@code errmsg} if validating permissions failed, otherwise {@code null}. - */ - public static String checkMissingFilePermissions(Context context, String path, String permissionsToCheck, String fileType, boolean ignoreIfNotExecutable) { - if (path == null || path.isEmpty()) return context.getString(R.string.error_null_or_empty_path); - - if (!isValidPermissingString(permissionsToCheck)) { - Logger.logError(LOG_TAG, "Invalid permissionsToCheck passed to checkMissingFilePermissions: \"" + permissionsToCheck + "\""); - return context.getString(R.string.error_invalid_file_permissions_string_to_check); - } - - if (fileType == null || fileType.isEmpty()) fileType = "File"; - - File file = new File(path); - - // If file is not readable - if (permissionsToCheck.contains("r") && !file.canRead()) { - return context.getString(R.string.error_file_not_readable, fileType); - } - - // If file is not writable - if (permissionsToCheck.contains("w") && !file.canWrite()) { - return context.getString(R.string.error_file_not_writable, fileType); - } - // If file is not executable - // This canExecute() will give "avc: granted { execute }" warnings for target sdk 29 - else if (permissionsToCheck.contains("x") && !file.canExecute() && !ignoreIfNotExecutable) { - return context.getString(R.string.error_file_not_executable, fileType); - } - - return null; - } - - /** - * Determines whether string exactly matches the 3 character permission string that - * contains the "r", "w", "x" or "-" in-order. - * - * @param string The {@link String} to check. - * @return Returns {@code true} if string exactly matches a permission string, otherwise {@code false}. - */ - public static boolean isValidPermissingString(String string) { - if (string == null || string.isEmpty()) return false; - return Pattern.compile("^([r-])[w-][x-]$", 0).matcher(string).matches(); - } - -} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 211fa379c8..89c4e1b148 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -104,21 +104,50 @@ Executable required. - The path is null or empty. - The file is null or empty. - The executable is null or empty. - The directory is null or empty. + The %1$s is to \"%2$s\" null or empty. + The regular file path is null or empty. + The regular file is null or empty. + The executable file path is null or empty. + The executable file is null or empty. + The directory file path is null or empty. + The directory file is null or empty. + + The %1$s is not found at path \"%2$s\". + Regular file not found at %1$s path. + The %1$s at path \"%2$s\" is not a regular file. + Non-regular file found at %1$s path. + Non-directory file found at %1$s path. + Non-symlink file found at %1$s path. + The %1$s found at path \"%2$s\" is not one of allowed file types \"%3$s\". + + Validating file existence and permissions of %1$s at path \"%2$s\" failed.\nException: %3$s + Validating directory existence and permissions of %1$s at path \"%2$s\" failed.\nException: %3$s + + Creating %1$s at path \"%2$s\" failed. + Creating %1$s at path \"%2$s\" failed.\nException: %3$s + + Cannot overwrite %1$s while creating symlink at \"%2$s\" to \"%3$s\" since destination file type \"%4$s\" is not a symlink. + Creating %1$s at path \"%2$s\" to \"%3$s\" failed.\nException: %4$s + + %1$s from \"%2$s\" to \"%3$s\" failed.\nException: %4$s + %1$s from \"%2$s\" to \"%3$s\" cannot be done since they point to the same path. + Cannot overwrite %1$s while %2$s it from \"%3$s\" to \"%4$s\" since destination file type \"%5$s\" is different from source file type \"%6$s\". + Cannot move %1$s from \"%2$s\" to \"%3$s\" since destination is a subdirectory of the source. + + The %1$s still exists after deleting it from \"%2$s\". + Deleting %1$s at path \"%2$s\" failed. + Deleting %1$s at path \"%2$s\" failed.\nException: %3$s + Clearing %1$s at path \"%2$s\" failed.\nException: %3$s + + Reading string from %1$s at path \"%2$s\" failed.\nException: %3$s + Writing string to %1$s at path \"%2$s\" failed.\nException: %3$s + Unsupported charset \"%1$s\" + Checking if charset \"%1$s\" is suppoted failed.\nException: %2$s + The file permission string to check is invalid. - Regular file not found at path. - Directory not found at path. - %1$s at path is not readable. Permission Denied. - %1$s at path is not writable. Permission Denied. - %1$s at path is not executable. Permission Denied. - Non-regular file found at path. - Non-directory file found at path. - Failed to create missing directory at path: \"%1$s\" - Validating file existence and permissions fafiled: \"%1$s\"\nException: %2$s - Validating directory existence and permissions fafiled: \"%1$s\"\nException: %2$s + The %1$s at path is not readable. Permission Denied. + The %1$s at path is not writable. Permission Denied. + The %1$s at path is not executable. Permission Denied.