import * as pathAPI from "@tauri-apps/api/path" import { BaseDirectory } from "@tauri-apps/api/path" import { exists, mkdir } from "@tauri-apps/plugin-fs" import { platform } from "@tauri-apps/plugin-os" import { minimatch } from "minimatch" import type { FsPermissionScoped, OpenPermissionScoped, ShellPermissionScoped } from "../permissions/schema" export async function combinePathAndBaseDir(target: string, baseDir?: BaseDirectory) { if (!baseDir) return target switch (baseDir) { case BaseDirectory.Desktop: return await pathAPI.join(await pathAPI.desktopDir(), target) case BaseDirectory.Document: return await pathAPI.join(await pathAPI.documentDir(), target) case BaseDirectory.Download: return await pathAPI.join(await pathAPI.downloadDir(), target) case BaseDirectory.Home: return await pathAPI.join(await pathAPI.homeDir(), target) case BaseDirectory.AppData: return await pathAPI.join(await pathAPI.appDataDir(), target) default: break } } export const mapDirAliasToDirFn: Record Promise> = { $DESKTOP: pathAPI.desktopDir, $DOCUMENT: pathAPI.documentDir, $DOWNLOAD: pathAPI.downloadDir, $HOME: pathAPI.homeDir, $APPDATA: pathAPI.appDataDir } export const AllPathAliases = [ ...Object.keys(mapDirAliasToDirFn), "$EXTENSION", "$EXTENSION_SUPPORT" ] export function pathStartsWithAlias(path: string) { return AllPathAliases.some((alias) => path.startsWith(alias)) } /** * @example * Translate $DESKTOP/* to /Users/username/Desktop/* * Translate $DOWNLOAD/** to /Users/username/Downloads/** * @param scope expected to be like $DESKTOP/*, $DOWNLOAD/**, $DOCUMENT/abc/*.txt */ export async function translateScopeToPath(scope: string, extensionDir: string): Promise { if (scope.startsWith("$EXTENSION_SUPPORT")) { const appDataDir = await pathAPI.appDataDir() if (extensionDir.startsWith(appDataDir)) { return pathAPI.join(appDataDir, "extensions_support", await pathAPI.basename(extensionDir)) } else { const extSupportDir = await pathAPI.join(extensionDir, "extensions_support") if (!(await exists(extSupportDir))) { await mkdir(extSupportDir, { recursive: true }) } return extSupportDir } } if (scope.startsWith("$EXTENSION")) { return pathAPI.join(extensionDir, scope.slice("$EXTENSION".length)) } for (const key of Object.keys(mapDirAliasToDirFn)) { if (scope.startsWith(key)) { const alias = key const pattern = scope.slice(key.length) const dirFn = mapDirAliasToDirFn[alias] if (!dirFn) { throw new Error(`Invalid scope alias: ${alias}`) } const fullDir = await dirFn() return pathAPI.join(fullDir, pattern) } } return scope throw new Error(`Invalid scope: ${scope}`) } /** * TODO: but this function also does security check to prevent parent directory traversal * @param target full path to file * @param scope expected to be like $DESKTOP/*, $DOWNLOAD/** */ export async function matchPathAndScope( target: string, scope: string, extensionDir: string ): Promise { let translatedTarget = await translateScopeToPath(target, extensionDir) let translatedScope = await translateScopeToPath(scope, extensionDir) if (platform() === "windows") { translatedTarget = translatedTarget.replaceAll("\\", "/") translatedScope = translatedScope.replaceAll("\\", "/") } return minimatch(translatedTarget, translatedScope) } /** * This is a helper function to verify scoped permission for path * If a scoped permission needs access to paths, this function verify whether the path is allowed by the permission * @param requiredPermissions * @param userPermissionScopes * @param path * @param options * @returns */ export async function verifyGeneralPathScopedPermission( requiredPermissions: T, userPermissionScopes: (FsPermissionScoped | OpenPermissionScoped | ShellPermissionScoped)[], path: string | URL, extensionDir: string, options?: { baseDir?: BaseDirectory } ) { path = path.toString() const fullPath = await combinePathAndBaseDir(path, options?.baseDir) if (!fullPath) { throw new Error("Invalid path or base directory") } const matchedPermissionScope = userPermissionScopes.filter((p) => requiredPermissions.includes(p.permission) ) if (matchedPermissionScope.length === 0) { throw new Error( `Path Permission denied. Require one of these: [${requiredPermissions.join(", ")}] for path: ${fullPath}` ) } for (const permission of matchedPermissionScope) { // deny has priority, if deny rule is matched, we ignore allow rule for (const deny of permission.deny || []) { if (!deny.path) continue if (await matchPathAndScope(fullPath, deny.path, extensionDir)) { throw new Error(`Permission denied for path: ${fullPath} by rule ${deny.path}`) } } for (const allow of permission.allow || []) { if (!allow.path) continue if (await matchPathAndScope(fullPath, allow.path, extensionDir)) { return } } } // No Allow rule and path matched throw new Error(`Permission denied for path: ${path}, no rule matched.`) } /** * This permission verifier helps to verify scoped permission for URL or path * Pass in user scoped permission, value and key to verify, return true if permission is allowed * @param userPermissionScopes * @param value * @param key * @returns */ export async function verifyScopedPermission( userPermissionScopes: (FsPermissionScoped | OpenPermissionScoped | ShellPermissionScoped)[], value: string, key: "url" | "path", extensionDir: string ): Promise { async function match(value: string, scope: string): Promise { if (key === "url") { return minimatch(value, scope) } else if (key === "path") { return matchPathAndScope(value, scope, extensionDir) } else { throw new Error(`Invalid key: ${key}`) } } let pass = false for (const permission of userPermissionScopes) { for (const allow of permission.allow || []) { if (allow[key] && (await match(value, allow[key]))) { pass = true break } } if (pass) { break } for (const deny of permission.deny || []) { if (deny[key] && (await match(value, deny[key]))) { pass = false break } } } return pass }