diff --git a/app/src/main/java/com/termux/app/RunCommandService.java b/app/src/main/java/com/termux/app/RunCommandService.java index f695967e..a5236e7c 100644 --- a/app/src/main/java/com/termux/app/RunCommandService.java +++ b/app/src/main/java/com/termux/app/RunCommandService.java @@ -10,27 +10,79 @@ import android.net.Uri; import android.os.Binder; import android.os.Build; import android.os.IBinder; +import android.util.Log; 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.settings.properties.SharedProperties; -import com.termux.app.settings.properties.TermuxPropertyConstants; +import com.termux.app.utils.FileUtils; import com.termux.app.utils.Logger; +import com.termux.app.utils.PluginUtils; + +import java.util.Arrays; +import java.util.HashMap; /** * Third-party apps that are not part of termux world can run commands in termux context by either * sending an intent to RunCommandService or becoming a plugin host for the termux-tasker plugin * client. * - * For the {@link RUN_COMMAND_SERVICE#ACTION_RUN_COMMAND} intent to work, there are 2 main requirements: + * For the {@link RUN_COMMAND_SERVICE#ACTION_RUN_COMMAND} intent to work, here are the requirements: * - * 1. The `allow-external-apps` property must be set to "true" in ~/.termux/termux.properties in - * termux app, regardless of if the executable path is inside or outside the `~/.termux/tasker/` - * directory. - * 2. The intent sender/third-party app must request the `com.termux.permission.RUN_COMMAND` + * 1. `com.termux.permission.RUN_COMMAND` permission (Mandatory) + * The Intent sender/third-party app must request the `com.termux.permission.RUN_COMMAND` * permission in its `AndroidManifest.xml` and it should be granted by user to the app through the - * app's App Info permissions page in android settings, likely under Additional Permissions. + * app's `App Info` `Permissions` page in Android Settings, likely under `Additional Permissions`. + * This is a security measure to prevent any other apps from running commands in `Termux` context + * which do not have the required permission granted to them. + * + * For `Tasker` you can grant it with: + * `Android Settings` -> `Apps` -> `Tasker` -> `Permissions` -> `Additional permissions` -> + * `Run commands in Termux environment`. + * + * 2. `allow-external-apps` property (Mandatory) + * The `allow-external-apps` property must be set to "true" in `~/.termux/termux.properties` in + * Termux app, regardless of if the executable path is inside or outside the `~/.termux/tasker/` + * directory. Check https://github.com/termux/termux-tasker#allow-external-apps-property-optional + * for more info. + * + * 3. `Draw Over Apps` permission (Optional) + * For android `>= 10` there are new + * [restrictions](https://developer.android.com/guide/components/activities/background-starts) + * that prevent activities from starting from the background. This prevents the background + * {@link TermuxService} from starting a terminal session in the foreground and running the + * commands until the user manually clicks `Termux` notification in the status bar dropdown + * notifications list. This only affects commands that are to be executed in a terminal + * session and not the background ones. `Termux` version `>= 0.100` + * requests the `Draw Over Apps` permission so that users can bypass this restriction so + * that commands can automatically start running without user intervention. + * You can grant `Termux` the `Draw Over Apps` permission from its `App Info` activity: + * `Android Settings` -> `Apps` -> `Termux` -> `Advanced` -> `Draw over other apps`. + * + * 4. `Storage` permission (Optional) + * Termux app must be granted `Storage` permission if the executable is accessing or working + * directory is set to path in external shared storage. The common paths which usually refer to + * it are `~/storage`, `/sdcard`, `/storage/emulated/0` etc. + * You can grant `Termux` the `Storage` permission from its `App Info` activity: + * For Android version < 11: + * `Android Settings` -> `Apps` -> `Termux` -> `Permissions` -> `Storage`. + * For Android version >= 11 + * `Android Settings` -> `Apps` -> `Termux` -> `Permissions` -> `Files and media` -> + * `Allowed management of all files`. + * NOTE: For Android version >= 11, sometimes you will get `Permission Denied` errors for + * external shared storage even when you have granted `Files and media` permission. To solve + * this, Deny the permission and then Allow it again and restart Termux. + * Also check https://wiki.termux.com/wiki/Termux-setup-storage + * + * 5. Battery Optimizations (May be mandatory depending on device) + * Some devices kill apps aggressively or prevent apps from starting from background. + * If Termux is running into such problems, then exempt it from such restrictions. + * The user may also disable battery optimizations for Termux to reduce the chances of Termux + * being killed by Android even further due to violation of not being able to call + * `startForeground()` within ~5s of service start in android >= 8. + * Check https://dontkillmyapp.com/ for device specfic info to opt-out of battery optimiations. + * + * You may also want to check https://github.com/termux/termux-tasker * * * @@ -55,21 +107,6 @@ import com.termux.app.utils.Logger; * The "$PREFIX/" will expand to {@link TermuxConstants#TERMUX_PREFIX_DIR_PATH} and * "~/" will expand to {@link TermuxConstants#TERMUX_HOME_DIR_PATH}, followed by a forward slash "/". * - * - * To automatically bring termux session to foreground and start termux commands that were started - * with background mode "false" in android >= 10 without user having to click the notification - * manually requires termux to be granted draw over apps permission due to new restrictions - * of starting activities from the background, this also applies to Termux:Tasker plugin. - * - * Check https://github.com/termux/termux-tasker for more details on allow-external-apps and draw - * over apps and other limitations. - * - * - * To reduce the chance of termux being killed by android even further due to violation of not - * being able to call startForeground() within ~5s of service start in android >= 8, the user - * may disable battery optimizations for termux. - * - * * If your third-party app is targeting sdk 30 (android 11), then it needs to add `com.termux` * package to the `queries` element or request `QUERY_ALL_PACKAGES` permission in its * `AndroidManifest.xml`. Otherwise it will get `PackageSetting{...... com.termux/......} BLOCKED` @@ -129,44 +166,80 @@ public class RunCommandService extends Service { // Run again in case service is already started and onCreate() is not called runStartForeground(); + String errmsg; + // If invalid action passed, then just return if (!RUN_COMMAND_SERVICE.ACTION_RUN_COMMAND.equals(intent.getAction())) { - Logger.logError(LOG_TAG, "Invalid intent action to RunCommandService: \"" + intent.getAction() + "\""); + errmsg = this.getString(R.string.run_command_service_invalid_action, intent.getAction()); + Logger.logError(LOG_TAG, errmsg); return Service.START_NOT_STICKY; } - // If allow-external-apps property is not set to "true" - if (!SharedProperties.isPropertyValueTrue(this, TermuxPropertyConstants.getTermuxPropertiesFile(), TermuxConstants.PROP_ALLOW_EXTERNAL_APPS)) { - Logger.logError(LOG_TAG, "RunCommandService requires allow-external-apps property to be set to \"true\" in \"" + TermuxConstants.TERMUX_PROPERTIES_PRIMARY_FILE_PATH + "\" file"); + // If "allow-external-apps" property to not set to "true", then just return + errmsg = PluginUtils.checkIfRunCommandServiceAllowExternalAppsPolicyIsViolated(this); + if (errmsg != null) { + Logger.logError(LOG_TAG, errmsg); return Service.START_NOT_STICKY; } - - String commandPath = intent.getStringExtra(RUN_COMMAND_SERVICE.EXTRA_COMMAND_PATH); - // If invalid commandPath passed, then just return - if (commandPath == null || commandPath.isEmpty()) { - Logger.logError(LOG_TAG, "Invalid coommand path to RunCommandService: \"" + commandPath + "\""); - return Service.START_NOT_STICKY; - } - - Uri programUri = new Uri.Builder().scheme(TERMUX_SERVICE.URI_SCHEME_SERVICE_EXECUTE).path(getExpandedTermuxPath(commandPath)).build(); - - - - Intent execIntent = new Intent(TERMUX_SERVICE.ACTION_SERVICE_EXECUTE, programUri); - execIntent.setClass(this, TermuxService.class); - execIntent.putExtra(TERMUX_SERVICE.EXTRA_ARGUMENTS, intent.getStringArrayExtra(RUN_COMMAND_SERVICE.EXTRA_ARGUMENTS)); - execIntent.putExtra(TERMUX_SERVICE.EXTRA_BACKGROUND, intent.getBooleanExtra(RUN_COMMAND_SERVICE.EXTRA_BACKGROUND, false)); - execIntent.putExtra(TERMUX_SERVICE.EXTRA_SESSION_ACTION, intent.getStringExtra(RUN_COMMAND_SERVICE.EXTRA_SESSION_ACTION)); - + String executable = intent.getStringExtra(RUN_COMMAND_SERVICE.EXTRA_COMMAND_PATH); + String[] arguments = intent.getStringArrayExtra(RUN_COMMAND_SERVICE.EXTRA_ARGUMENTS); + boolean inBackground = intent.getBooleanExtra(RUN_COMMAND_SERVICE.EXTRA_BACKGROUND, false); String workingDirectory = intent.getStringExtra(RUN_COMMAND_SERVICE.EXTRA_WORKDIR); - if (workingDirectory != null && !workingDirectory.isEmpty()) { - execIntent.putExtra(TERMUX_SERVICE.EXTRA_WORKDIR, getExpandedTermuxPath(workingDirectory)); + String sessionAction = intent.getStringExtra(RUN_COMMAND_SERVICE.EXTRA_SESSION_ACTION); + + // Get canonical path of executable + executable = FileUtils.getCanonicalPath(executable, null, true); + + // 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, executable, + null, PluginUtils.PLUGIN_EXECUTABLE_FILE_PERMISSIONS, + false, false); + if (errmsg != null) { + errmsg += "\n" + this.getString(R.string.executable_absolute_path, executable); + Logger.logError(LOG_TAG, errmsg); + return Service.START_NOT_STICKY; } + // If workingDirectory is not null or empty + if (workingDirectory != null && !workingDirectory.isEmpty()) { + // Get canonical path of workingDirectory + workingDirectory = FileUtils.getCanonicalPath(workingDirectory, null, true); + + // If workingDirectory is not a directory, or is not readable or writable, then just return + // Creation of missing directory and setting of read, write and execute permissions are only done if workingDirectory is + // 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, workingDirectory, + TermuxConstants.TERMUX_FILES_DIR_PATH, PluginUtils.PLUGIN_WORKING_DIRECTORY_PERMISSIONS, + true, true, false, + true); + if (errmsg != null) { + errmsg += "\n" + this.getString(R.string.working_directory_absolute_path, workingDirectory); + Logger.logError(LOG_TAG, errmsg); + return Service.START_NOT_STICKY; + } + } + + PluginUtils.dumpExecutionIntentToLog(Log.VERBOSE, LOG_TAG, "RUN_COMMAND Intent", executable, Arrays.asList(arguments), workingDirectory, inBackground, new HashMap() {{ + put("sessionAction", sessionAction); + }}); + + Uri executableUri = new Uri.Builder().scheme(TERMUX_SERVICE.URI_SCHEME_SERVICE_EXECUTE).path(FileUtils.getExpandedTermuxPath(executable)).build(); + + // Create execution intent with the action TERMUX_SERVICE#ACTION_SERVICE_EXECUTE to be sent to the TERMUX_SERVICE + Intent execIntent = new Intent(TERMUX_SERVICE.ACTION_SERVICE_EXECUTE, executableUri); + execIntent.setClass(this, TermuxService.class); + execIntent.putExtra(TERMUX_SERVICE.EXTRA_ARGUMENTS, arguments); + execIntent.putExtra(TERMUX_SERVICE.EXTRA_BACKGROUND, inBackground); + if (workingDirectory != null && !workingDirectory.isEmpty()) execIntent.putExtra(TERMUX_SERVICE.EXTRA_WORKDIR, workingDirectory); + execIntent.putExtra(TERMUX_SERVICE.EXTRA_SESSION_ACTION, sessionAction); + // Start TERMUX_SERVICE and pass it execution intent if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { this.startForegroundService(execIntent); } else { @@ -223,15 +296,4 @@ public class RunCommandService extends Service { manager.createNotificationChannel(channel); } - /** Replace "$PREFIX/" or "~/" prefix with termux absolute paths */ - 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; - } } diff --git a/app/src/main/java/com/termux/app/utils/FileUtils.java b/app/src/main/java/com/termux/app/utils/FileUtils.java new file mode 100644 index 00000000..f50ea91c --- /dev/null +++ b/app/src/main/java/com/termux/app/utils/FileUtils.java @@ -0,0 +1,376 @@ +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.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.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.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.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.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.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.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.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.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.null_or_empty_path); + + if (!isValidPermissingString(permissionsToCheck)) { + Logger.logError(LOG_TAG, "Invalid permissionsToCheck passed to checkMissingFilePermissions: \"" + permissionsToCheck + "\""); + return context.getString(R.string.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.file_not_readable, fileType); + } + + // If file is not writable + if (permissionsToCheck.contains("w") && !file.canWrite()) { + return context.getString(R.string.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.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/java/com/termux/app/utils/PluginUtils.java b/app/src/main/java/com/termux/app/utils/PluginUtils.java new file mode 100644 index 00000000..e59af7f8 --- /dev/null +++ b/app/src/main/java/com/termux/app/utils/PluginUtils.java @@ -0,0 +1,180 @@ +package com.termux.app.utils; + +import android.app.Activity; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; + +import com.termux.R; +import com.termux.app.TermuxConstants; +import com.termux.app.TermuxConstants.TERMUX_APP.TERMUX_SERVICE; +import com.termux.app.settings.properties.SharedProperties; +import com.termux.app.settings.properties.TermuxPropertyConstants; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class PluginUtils { + + /** Plugin variable for stdout value of termux command */ + public static final String PLUGIN_VARIABLE_STDOUT = "%stdout"; // Default: "%stdout" + /** Plugin variable for stderr value of termux command */ + public static final String PLUGIN_VARIABLE_STDERR = "%stderr"; // Default: "%stderr" + /** Plugin variable for exit code value of termux command */ + public static final String PLUGIN_VARIABLE_EXIT_CODE = "%result"; // Default: "%result" + /** Plugin variable for err value of termux command */ + public static final String PLUGIN_VARIABLE_ERR = "%err"; // Default: "%err" + /** Plugin variable for errmsg value of termux command */ + public static final String PLUGIN_VARIABLE_ERRMSG = "%errmsg"; // Default: "%errmsg" + + /** Intent {@code Parcelable} extra containing original intent received from plugin host app by FireReceiver */ + public static final String EXTRA_ORIGINAL_INTENT = "originalIntent"; // Default: "originalIntent" + + + + /** Required file permissions for the executable file of execute intent. Executable file must have read and execute permissions */ + public static final String PLUGIN_EXECUTABLE_FILE_PERMISSIONS = "r-x"; // Default: "r-x" + /** Required file permissions for the working directory of execute intent. Working directory must have read and write permissions. + * Execute permissions should be attempted to be set, but ignored if they are missing */ + public static final String PLUGIN_WORKING_DIRECTORY_PERMISSIONS = "rwx"; // Default: "rwx" + + + + /** + * A regex to validate if a string matches a valid plugin host variable name with the percent sign "%" prefix. + * Valid values: A string containing a percent sign character "%", followed by 1 alphanumeric character, + * followed by 2 or more alphanumeric or underscore "_" characters but does not end with an underscore "_" + */ + public static final String PLUGIN_HOST_VARIABLE_NAME_MATCH_EXPRESSION = "%[a-zA-Z0-9][a-zA-Z0-9_]{2,}(? arguments_list, String workingDirectory, boolean inBackground, HashMap additionalExtras) { + if (label == null) label = "Execution Intent"; + + StringBuilder executionIntentDump = new StringBuilder(); + + executionIntentDump.append(label).append(":\n"); + executionIntentDump.append("Executable: `").append(executable).append("`\n"); + executionIntentDump.append("Arguments:").append(getArgumentsStringForLog(arguments_list)).append("\n"); + executionIntentDump.append("Working Directory: `").append(workingDirectory).append("`\n"); + executionIntentDump.append("inBackground: `").append(inBackground).append("`"); + + if(additionalExtras != null) { + for (Map.Entry entry : additionalExtras.entrySet()) { + executionIntentDump.append("\n").append(entry.getKey()).append(": `").append(entry.getValue()).append("`"); + } + } + + Logger.logMesssage(logLevel, logTag, executionIntentDump.toString()); + } + + /** + * Converts arguments list to log friendly format. If arguments are null or of size 0, then + * nothing is returned. Otherwise following format is returned: + * + * ``` + * Arg 0: `value` + * Arg 1: 'value` + * ``` + * + * @param arguments_list The arguments list. + * @return Returns the formatted arguments list. + */ + public static String getArgumentsStringForLog(List arguments_list) { + if (arguments_list==null || arguments_list.size() == 0) return ""; + + StringBuilder arguments_list_string = new StringBuilder("\n```\n"); + for(int i = 0; i != arguments_list.size(); i++) { + arguments_list_string.append("Arg ").append(i).append(": `").append(arguments_list.get(i)).append("`\n"); + } + arguments_list_string.append("```"); + + return arguments_list_string.toString(); + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 97b65a37..9de363d1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -9,6 +9,7 @@ + ]> @@ -64,6 +65,29 @@ Edit Open folder + Executable Absolute Path: \"%1$s\" + Working Directory Absolute Path: \"%1$s\" + + 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 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 + + Invalid intent action to RunCommandService: \"%1$s\" + Invalid coommand path to RunCommandService: \"%1$s\" + RunCommandService require allow-external-apps property to be set to \"true\" in &TERMUX_PROPERTIES_PRIMARY_PATH_SHORT; file.