Refactor RunCommandService

- The `FileUtils` and `PluginUtils` have been added to provide utility functions.
- The executable and working directory validation has been added to check for existence and missing permissions.
- The `expandPath()` function is removed from `RunCommandService`.
- Working directory will automatically be created if under `TermuxConstants.TERMUX_FILES_DIR_PATH` if missing.
- Better logging has been added. This will later be used to notify the user in foreground.
- Javadocs have been updated.
This commit is contained in:
agnostic-apollo
2021-03-19 18:40:26 +05:00
parent ec7568d28e
commit b4995ef9a7
4 changed files with 700 additions and 58 deletions

View File

@@ -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<String, Object>() {{
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;
}
}

View File

@@ -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();
}
}

View File

@@ -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,}(?<!_)";
private static final String LOG_TAG = "PluginUtils";
/**
* Send execution result of commands to the {@link PendingIntent} creator received by
* execution service if {@code pendingIntent} is not {@code null}
*
* @param logLevel The log level to dump the result.
* @param logTag The log tag to use for logging.
* @param context The {@link Context} that will be used to send result intent to the PluginResultsService.
* @param pendingIntent The {@link PendingIntent} sent by creator to the execution service.
* @param stdout The value for {@link TERMUX_SERVICE#EXTRA_STDOUT} extra of {@link TERMUX_SERVICE#EXTRA_RESULT_BUNDLE} bundle of intent.
* @param stderr The value for {@link TERMUX_SERVICE#EXTRA_STDERR} extra of {@link TERMUX_SERVICE#EXTRA_RESULT_BUNDLE} bundle of intent.
* @param exitCode The value for {@link TERMUX_SERVICE#EXTRA_EXIT_CODE} extra of {@link TERMUX_SERVICE#EXTRA_RESULT_BUNDLE} bundle of intent.
* @param errCode The value for {@link TERMUX_SERVICE#EXTRA_ERR} extra of {@link TERMUX_SERVICE#EXTRA_RESULT_BUNDLE} bundle of intent.
* @param errmsg The value for {@link TERMUX_SERVICE#EXTRA_ERRMSG} extra of {@link TERMUX_SERVICE#EXTRA_RESULT_BUNDLE} bundle of intent.
*/
public static void sendExecuteResultToResultsService(final int logLevel, final String logTag, final Context context, final PendingIntent pendingIntent, final String stdout, final String stderr, final String exitCode, final String errCode, final String errmsg) {
String label;
if(pendingIntent == null)
label = "Execution Result";
else
label = "Sending execution result to " + pendingIntent.getCreatorPackage();
Logger.logMesssage(logLevel, logTag, label + ":\n" +
TERMUX_SERVICE.EXTRA_STDOUT + ":\n```\n" + stdout + "\n```\n" +
TERMUX_SERVICE.EXTRA_STDERR + ":\n```\n" + stderr + "\n```\n" +
TERMUX_SERVICE.EXTRA_EXIT_CODE + ": `" + exitCode + "`\n" +
TERMUX_SERVICE.EXTRA_ERR + ": `" + errCode + "`\n" +
TERMUX_SERVICE.EXTRA_ERRMSG + ": `" + errmsg + "`");
// If pendingIntent is null, then just return
if(pendingIntent == null) return;
final Bundle resultBundle = new Bundle();
resultBundle.putString(TERMUX_SERVICE.EXTRA_STDOUT, stdout);
resultBundle.putString(TERMUX_SERVICE.EXTRA_STDERR, stderr);
if (exitCode != null && !exitCode.isEmpty()) resultBundle.putInt(TERMUX_SERVICE.EXTRA_EXIT_CODE, Integer.parseInt(exitCode));
if (errCode != null && !errCode.isEmpty()) resultBundle.putInt(TERMUX_SERVICE.EXTRA_ERR, Integer.parseInt(errCode));
resultBundle.putString(TERMUX_SERVICE.EXTRA_ERRMSG, errmsg);
Intent resultIntent = new Intent();
resultIntent.putExtra(TERMUX_SERVICE.EXTRA_RESULT_BUNDLE, resultBundle);
if(context != null) {
try {
pendingIntent.send(context, Activity.RESULT_OK, resultIntent);
} catch (PendingIntent.CanceledException e) {
// The caller doesn't want the result? That's fine, just ignore
}
}
}
/**
* Check if {@link TermuxConstants#PROP_ALLOW_EXTERNAL_APPS} property is not set to "true".
*
* @param context The {@link Context} to get error string.
* @return Returns the {@code errmsg} if policy is violated, otherwise {@code null}.
*/
public static String checkIfRunCommandServiceAllowExternalAppsPolicyIsViolated(final Context context) {
String errmsg = null;
if (!SharedProperties.isPropertyValueTrue(context, TermuxPropertyConstants.getTermuxPropertiesFile(), TermuxConstants.PROP_ALLOW_EXTERNAL_APPS)) {
errmsg = context.getString(R.string.run_command_service_allow_external_apps_ungranted_warning);
}
return errmsg;
}
/**
* Send execution result of commands to the {@link PendingIntent} creator received by
* execution service if {@code pendingIntent} is not {@code null}
*
* @param logLevel The log level to dump the result.
* @param logTag The log tag to use for logging.
* @param executable The executable received.
* @param arguments_list The arguments list for executable received.
* @param workingDirectory The working directory for the command received.
* @param inBackground The command should be run in background.
* @param additionalExtras The {@link HashMap} for additional extras received. The key will be
* used as the label to log the value. The object will be converted
* to {@link String} with a call to {@code value.toString()}.
*/
public static void dumpExecutionIntentToLog(int logLevel, String logTag, String label, String executable, List<String> arguments_list, String workingDirectory, boolean inBackground, HashMap<String, Object> 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<String, Object> 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<String> 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();
}
}

View File

@@ -9,6 +9,7 @@
<!ENTITY TERMUX_STYLING_APP_NAME "Termux:Styling">
<!ENTITY TERMUX_TASKER_APP_NAME "Termux:Tasker">
<!ENTITY TERMUX_WIDGET_APP_NAME "Termux:Widget">
<!ENTITY TERMUX_PROPERTIES_PRIMARY_PATH_SHORT "~/.termux/termux.properties">
]>
<resources>
@@ -64,6 +65,29 @@
<string name="file_received_edit_button">Edit</string>
<string name="file_received_open_folder_button">Open folder</string>
<string name="executable_absolute_path">Executable Absolute Path: \"%1$s\"</string>
<string name="working_directory_absolute_path">Working Directory Absolute Path: \"%1$s\"</string>
<string name="executable_required">Executable required.</string>
<string name="null_or_empty_path">The path is null or empty.</string>
<string name="null_or_empty_file">The file is null or empty.</string>
<string name="null_or_empty_executable">The executable is null or empty.</string>
<string name="null_or_empty_directory">The directory is null or empty.</string>
<string name="invalid_file_permissions_string_to_check">The file permission string to check is invalid.</string>
<string name="no_regular_file_found">Regular file not found at path.</string>
<string name="no_directory_found">Directory not found at path.</string>
<string name="file_not_readable">%1$s at path is not readable. Permission Denied.</string>
<string name="file_not_writable">%1$s at path is not writable. Permission Denied.</string>
<string name="file_not_executable">%1$s at path is not executable. Permission Denied.</string>
<string name="non_regular_file_found">Non-regular file found at path.</string>
<string name="non_directory_file_found">Non-directory file found at path.</string>
<string name="creating_missing_directory_failed">Failed to create missing directory at path: \"%1$s\"</string>
<string name="validate_file_existence_and_permissions_failed_with_exception">Validating file existence and permissions fafiled: \"%1$s\"\nException: %2$s</string>
<string name="validate_directory_existence_and_permissions_failed_with_exception">Validating directory existence and permissions fafiled: \"%1$s\"\nException: %2$s</string>
<string name="run_command_service_invalid_action">Invalid intent action to RunCommandService: \"%1$s\"</string>
<string name="run_command_service_invalid_command_path">Invalid coommand path to RunCommandService: \"%1$s\"</string>
<string name="run_command_service_allow_external_apps_ungranted_warning">RunCommandService require allow-external-apps property to be set to \"true\" in &TERMUX_PROPERTIES_PRIMARY_PATH_SHORT; file.</string>