From d4fc34ca2d771a4586098707f32f31400b7b3c6e Mon Sep 17 00:00:00 2001 From: agnostic-apollo Date: Tue, 6 Apr 2021 06:00:05 +0500 Subject: [PATCH] Move FileUtils to file package and define more file util functions A lot of utils have been defined now that can be used to safely manage files. The java java.io.File API has poor support for detecting symlinks including broken symlinks. Android implementation also has issues. Check FileTypes.getFileType() function for more info. For this reason, the UnixFileAttributes and related classes has been ported from AOSP to get file attributes and type. Some file utils and android versions use google's Guava com.google.common.io.MoreFiles library for managing files, specially for safer directory deletion with SecureDirectoryStream. Some file utils and android versions use org.apache.commons.io.FileUtils for managing files. The library version used is 2.5 and it must not be incremented for compatibility with android version < 8, otherwise runtime crashes will occur. --- app/build.gradle | 28 +- .../com/termux/app/RunCommandService.java | 15 +- .../java/com/termux/app/file/FileUtils.java | 1627 +++++++++++++++++ .../app/file/filesystem/FileAttributes.java | 416 +++++ .../termux/app/file/filesystem/FileKey.java | 68 + .../app/file/filesystem/FilePermission.java | 87 + .../app/file/filesystem/FilePermissions.java | 145 ++ .../termux/app/file/filesystem/FileTime.java | 156 ++ .../termux/app/file/filesystem/FileType.java | 31 + .../termux/app/file/filesystem/FileTypes.java | 116 ++ .../app/file/filesystem/NativeDispatcher.java | 58 + .../app/file/filesystem/UnixConstants.java | 149 ++ .../termux/app/file/tests/FileUtilsTests.java | 301 +++ .../java/com/termux/app/utils/FileUtils.java | 376 ---- app/src/main/res/values/strings.xml | 57 +- 15 files changed, 3219 insertions(+), 411 deletions(-) create mode 100644 app/src/main/java/com/termux/app/file/FileUtils.java create mode 100644 app/src/main/java/com/termux/app/file/filesystem/FileAttributes.java create mode 100644 app/src/main/java/com/termux/app/file/filesystem/FileKey.java create mode 100644 app/src/main/java/com/termux/app/file/filesystem/FilePermission.java create mode 100644 app/src/main/java/com/termux/app/file/filesystem/FilePermissions.java create mode 100644 app/src/main/java/com/termux/app/file/filesystem/FileTime.java create mode 100644 app/src/main/java/com/termux/app/file/filesystem/FileType.java create mode 100644 app/src/main/java/com/termux/app/file/filesystem/FileTypes.java create mode 100644 app/src/main/java/com/termux/app/file/filesystem/NativeDispatcher.java create mode 100644 app/src/main/java/com/termux/app/file/filesystem/UnixConstants.java create mode 100644 app/src/main/java/com/termux/app/file/tests/FileUtilsTests.java delete mode 100644 app/src/main/java/com/termux/app/utils/FileUtils.java diff --git a/app/build.gradle b/app/build.gradle index 22bfe3ee..17691e8a 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 5c236258..67339f6f 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 android.os.IBinder; 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 class RunCommandService extends Service { // 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 class RunCommandService extends Service { // 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 00000000..4e5abadd --- /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 00000000..03482136 --- /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 00000000..87908a26 --- /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 00000000..6cb4dd19 --- /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 00000000..eb48579c --- /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 00000000..cf0f30fd --- /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 00000000..2378456b --- /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 00000000..6f750715 --- /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 00000000..d4495373 --- /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 00000000..0cc7a79d --- /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 00000000..21db1d57 --- /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 194e37f9..00000000 --- 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 211fa379..89c4e1b1 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.