diff --git a/README.md b/README.md index b012ace..94e8c12 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,7 @@ Full examples: - [`apps/recursive`](./apps/recursive/README.md): An example application with few nested sandbox instances. - [`apps/p2p-chat`](./apps/p2p-counter/README.md): Direct sandbox-to-sandbox chat demo. - [`apps/p2p-counter`](./apps/p2p-counter/README.md): Direct sandbox-to-sandbox communication demo. +- [`apps/fs-experiment`](./apps/fs-experiment/README.md): File system & storage isolation with TurboModule substitutions. ## 📚 API Reference @@ -168,9 +169,15 @@ We're actively working on expanding the capabilities of `react-native-sandbox`. - Resource usage limits and monitoring - Sandbox capability restrictions - Unresponsiveness detection -- [ ] **Storage Isolation** - Secure data partitioning - - Per-sandbox AsyncStorage isolation - - Secure file system access controls +- [x] **TurboModule Substitutions** - Replace native module implementations per sandbox + - Configurable via `turboModuleSubstitutions` prop (JS/TS only) + - Sandbox-aware modules receive origin context for per-instance scoping + - Supports both TurboModules (new arch) and legacy bridge modules +- [x] **Storage & File System Isolation** - Secure data partitioning + - Per-sandbox AsyncStorage isolation via scoped storage directories + - Sandboxed file system access (react-native-fs, react-native-file-access) with path jailing + - All directory constants overridden to sandbox-scoped paths + - Network/system operations blocked in sandboxed FS modules - [ ] **Developer Tools** - Enhanced debugging and development experience Contributions and feedback on these roadmap items are welcome! Please check our [issues](https://github.com/callstackincubator/react-native-sandbox/issues) for detailed discussions on each feature. @@ -185,9 +192,24 @@ A primary security concern when running multiple React Native instances is the p - **Data Leakage:** One sandbox could use a shared TurboModule to store data, which could then be read by another sandbox or the host. This breaks the isolation model. - **Unintended Side-Effects:** A sandbox could call a method on a shared module that changes its state, affecting the behavior of the host or other sandboxes in unpredictable ways. -To address this, `react-native-sandbox` allows you to provide a **whitelist of allowed TurboModules** for each sandbox instance via the `allowedTurboModules` prop. Only the modules specified in this list will be accessible from within the sandbox, significantly reducing the attack surface. It is critical to only whitelist modules that are stateless or are explicitly designed to be shared safely. +To address this, `react-native-sandbox` provides two mechanisms: -**Default Whitelist:** By default, only `NativeMicrotasksCxx` is whitelisted. Modules like `NativePerformanceCxx`, `PlatformConstants`, `DevSettings`, `LogBox`, and other third-party modules are *not* whitelisted. +- **TurboModule Allowlisting** — Use the `allowedTurboModules` prop to control which native modules the sandbox can access. Only modules in this list are resolved; all others return a stub that rejects with a clear error. + +- **TurboModule Substitutions** — Use the `turboModuleSubstitutions` prop to transparently replace a module with a sandbox-aware alternative. For example, when sandbox JS requests `RNCAsyncStorage`, the host can resolve different implementation like `SandboxedAsyncStorage` instead — an implementation that scopes storage to the sandbox's origin. Substituted modules that conform to `RCTSandboxAwareModule` (ObjC) or `ISandboxAwareModule` (C++) receive the sandbox context (origin, requested name, resolved name) after instantiation. + +```tsx + +``` + +**Default Allowlist:** A baseline set of essential React Native modules is allowed by default (e.g., `EventDispatcher`, `AppState`, `Appearance`, `Networking`, `DeviceInfo`, `KeyboardObserver`, and others required for basic rendering and dev tooling). See the [full list in source](https://github.com/callstackincubator/react-native-sandbox/blob/main/packages/react-native-sandbox/src/index.tsx). Third-party modules and storage/FS modules are *not* included — they must be explicitly added via `allowedTurboModules` or provided through `turboModuleSubstitutions`. ### Performance @@ -195,7 +217,11 @@ To address this, `react-native-sandbox` allows you to provide a **whitelist of a ### File System & Storage -- **Persistent Storage Conflicts:** Standard APIs like `AsyncStorage` may not be instance-aware, potentially allowing a sandbox to read or overwrite data stored by the host or other sandboxes. Any storage mechanism must be properly partitioned to ensure data isolation. +- **Persistent Storage Conflicts:** Standard APIs like `AsyncStorage` are not instance-aware by default, potentially allowing a sandbox to read or overwrite data stored by the host or other sandboxes. Use `turboModuleSubstitutions` to replace these modules with sandbox-aware implementations that scope data per origin. +- **File System Path Jailing:** Sandboxed file system modules (`SandboxedRNFSManager`, `SandboxedFileAccess`) override directory constants and validate all path arguments, ensuring file operations are confined to a per-origin sandbox directory. Paths outside the sandbox root are rejected with `EPERM`. +- **Network Operations Blocked:** Sandboxed FS modules block download/upload/fetch operations to prevent data exfiltration. + +See the [`apps/fs-experiment`](./apps/fs-experiment/) example for a working demonstration. ### Platform-Specific Considerations diff --git a/apps/fs-experiment/App.tsx b/apps/fs-experiment/App.tsx index 8e6d95c..500bacf 100644 --- a/apps/fs-experiment/App.tsx +++ b/apps/fs-experiment/App.tsx @@ -1,223 +1,110 @@ import SandboxReactNativeView from '@callstack/react-native-sandbox' import React, {useState} from 'react' import { - Platform, SafeAreaView, ScrollView, StatusBar, StyleSheet, + Switch, Text, - TextInput, - TouchableOpacity, useColorScheme, View, } from 'react-native' -// File system imports -import RNFS from 'react-native-fs' -const SHARED_FILE_PATH = `${RNFS.DocumentDirectoryPath}/shared_test_file.txt` +import FileOpsUI from './FileOpsUI' + +const ALL_TURBO_MODULES = ['RNFSManager', 'FileAccess', 'PlatformLocalStorage'] + +const SANDBOXED_SUBSTITUTIONS: Record = { + RNFSManager: 'SandboxedRNFSManager', + FileAccess: 'SandboxedFileAccess', + PlatformLocalStorage: 'SandboxedAsyncStorage', +} function App(): React.JSX.Element { const isDarkMode = useColorScheme() === 'dark' - const [textContent, setTextContent] = useState('') - const [status, setStatus] = useState('Ready') + const [useSubstitution, setUseSubstitution] = useState(false) const theme = { - background: isDarkMode ? '#000000' : '#ffffff', + bg: isDarkMode ? '#000' : '#fff', surface: isDarkMode ? '#1c1c1e' : '#f2f2f7', - primary: isDarkMode ? '#007aff' : '#007aff', - secondary: isDarkMode ? '#34c759' : '#34c759', - text: isDarkMode ? '#ffffff' : '#000000', - textSecondary: isDarkMode ? '#8e8e93' : '#3c3c43', - border: isDarkMode ? '#38383a' : '#c6c6c8', - success: '#34c759', - error: '#ff3b30', - } - - const writeFile = async () => { - try { - setStatus('Writing file...') - await RNFS.writeFile(SHARED_FILE_PATH, textContent, 'utf8') - setStatus(`Successfully wrote: "${textContent}"`) - } catch (error) { - setStatus(`Write error: ${(error as Error).message}`) - } - } - - const readFile = async () => { - try { - setStatus('Reading file...') - const content = await RNFS.readFile(SHARED_FILE_PATH, 'utf8') - setTextContent(content) - setStatus(`Successfully read: "${content}"`) - } catch (error) { - setStatus(`Read error: ${(error as Error).message}`) - } - } - - const getStatusStyle = () => { - if (status.includes('error')) { - return {color: theme.error} - } - if (status.includes('Successfully')) { - return {color: theme.success} - } - return {color: theme.textSecondary} + text: isDarkMode ? '#fff' : '#000', + textSec: isDarkMode ? '#8e8e93' : '#6c6c70', + border: isDarkMode ? '#38383a' : '#d1d1d6', + blue: '#007aff', + green: '#34c759', + orange: '#ff9500', } return ( - + - - {/* Header */} - - - File System Sandbox Demo - - - Multi-instance file system access testing - - - - {/* Host Application Section */} + + {/* ===== HOST ===== */} + - - - Host Application - - - Primary - - - - - - - - Write File - - - - Read File - - - - - - Status: - - - {status} - - - - - {SHARED_FILE_PATH} + style={[styles.sectionHeader, {backgroundColor: theme.surface}]}> + + Host App + + HOST + - {/* Sandbox Sections */} + + + + {/* ===== SANDBOX ===== */} + - - - Sandbox: react-native-fs - - - Sandbox - + style={[styles.sectionHeader, {backgroundColor: theme.surface}]}> + + Sandbox + + + SANDBOXED - { - console.log('Host received message from sandbox:', message) - }} - onError={error => { - console.log('Host received error from sandbox:', error) - }} - /> - - - - Sandbox: react-native-file-access + + + + Module substitution{' '} + + {useSubstitution ? '(safe)' : '(off)'} + - - Sandbox - + - { - console.log('Host received message from sandbox:', message) - }} - onError={error => { - console.log('Host received error from sandbox:', error) - }} - /> + + console.log('Host received from sandbox:', msg)} + onError={err => + console.log('Host received error from sandbox:', err) + } + /> @@ -225,138 +112,51 @@ function App(): React.JSX.Element { } const styles = StyleSheet.create({ - container: { + root: { flex: 1, }, - header: { - paddingHorizontal: 20, - paddingVertical: 24, - ...Platform.select({ - ios: { - shadowColor: '#000', - shadowOffset: {width: 0, height: 1}, - shadowOpacity: 0.1, - shadowRadius: 4, - }, - android: { - elevation: 2, - }, - }), - }, - headerTitle: { - fontSize: 28, - fontWeight: '700', - letterSpacing: -0.5, - }, - headerSubtitle: { - fontSize: 16, - marginTop: 4, - fontWeight: '400', + section: { + borderBottomWidth: StyleSheet.hairlineWidth, }, - content: { - padding: 16, - }, - card: { - marginBottom: 20, - borderRadius: 12, - padding: 20, - borderWidth: 1, - ...Platform.select({ - ios: { - shadowColor: '#000', - shadowOffset: {width: 0, height: 2}, - shadowOpacity: 0.1, - shadowRadius: 8, - }, - android: { - elevation: 3, - }, - }), - }, - cardHeader: { + sectionHeader: { flexDirection: 'row', - justifyContent: 'space-between', alignItems: 'center', - marginBottom: 16, + justifyContent: 'space-between', + paddingHorizontal: 16, + paddingVertical: 10, }, - cardTitle: { - fontSize: 18, - fontWeight: '600', - flex: 1, + sectionTitle: { + fontSize: 17, + fontWeight: '700', }, badge: { paddingHorizontal: 8, - paddingVertical: 4, - borderRadius: 6, + paddingVertical: 3, + borderRadius: 5, }, badgeText: { - color: '#ffffff', - fontSize: 12, - fontWeight: '600', - textTransform: 'uppercase', - }, - sandboxBadge: { - backgroundColor: '#ff6b35', + color: '#fff', + fontSize: 10, + fontWeight: '700', + letterSpacing: 0.5, }, - textInput: { - borderWidth: 1, - borderRadius: 8, - padding: 16, - marginBottom: 16, - minHeight: 100, - textAlignVertical: 'top', - fontSize: 16, - lineHeight: 22, + switchBar: { + paddingHorizontal: 16, + paddingVertical: 6, }, - buttonGroup: { + switchRow: { flexDirection: 'row', - gap: 12, - marginBottom: 16, - }, - button: { - flex: 1, - paddingVertical: 14, - paddingHorizontal: 20, - borderRadius: 8, alignItems: 'center', - justifyContent: 'center', - }, - primaryButton: { - // backgroundColor set dynamically - }, - secondaryButton: { - // backgroundColor set dynamically - }, - buttonText: { - color: '#ffffff', - fontWeight: '600', - fontSize: 16, - }, - statusContainer: { - padding: 12, - borderRadius: 8, - marginBottom: 12, + justifyContent: 'space-between', }, - statusLabel: { + switchLabel: { fontSize: 14, fontWeight: '500', - marginBottom: 4, - }, - statusText: { - fontSize: 14, - fontStyle: 'italic', - lineHeight: 20, - }, - pathText: { - fontSize: 12, - fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace', - opacity: 0.8, - lineHeight: 16, + flex: 1, }, - sandbox: { - height: 320, - borderWidth: 1, - borderRadius: 8, + sandboxView: { + height: 400, + marginBottom: 8, }, }) diff --git a/apps/fs-experiment/FileOpsUI.tsx b/apps/fs-experiment/FileOpsUI.tsx new file mode 100644 index 0000000..40beac9 --- /dev/null +++ b/apps/fs-experiment/FileOpsUI.tsx @@ -0,0 +1,312 @@ +import AsyncStorage from '@react-native-async-storage/async-storage' +import React, {useId, useState} from 'react' +import { + InputAccessoryView, + Keyboard, + Platform, + StyleSheet, + Text, + TextInput, + TouchableOpacity, + useColorScheme, + View, +} from 'react-native' +import {Dirs, FileSystem} from 'react-native-file-access' +import RNFS from 'react-native-fs' + +const MODULES = [ + {key: 'rnfs', label: 'RNFS'}, + {key: 'file-access', label: 'file-access'}, + {key: 'async-storage', label: 'async-storage'}, +] as const +type Module = (typeof MODULES)[number]['key'] + +interface FileOpsUIProps { + accentColor?: string +} + +export default function FileOpsUI({accentColor}: FileOpsUIProps) { + const isDarkMode = useColorScheme() === 'dark' + const [module, setModule] = useState('rnfs') + const [target, setTarget] = useState('secret') + const [text, setText] = useState('') + const [status, setStatus] = useState('Ready') + const accessoryId = useId() + + const theme = { + bg: isDarkMode ? '#000' : '#fff', + surface: isDarkMode ? '#1c1c1e' : '#f2f2f7', + text: isDarkMode ? '#fff' : '#000', + textSec: isDarkMode ? '#8e8e93' : '#6c6c70', + border: isDarkMode ? '#38383a' : '#d1d1d6', + accent: accentColor ?? '#007aff', + green: '#34c759', + red: '#ff3b30', + segBg: isDarkMode ? '#2c2c2e' : '#e8e8ed', + segActive: isDarkMode ? '#3a3a3c' : '#fff', + } + + const isStorage = module === 'async-storage' + + const getPath = () => { + switch (module) { + case 'rnfs': + return `${RNFS.DocumentDirectoryPath}/${target}` + case 'file-access': + return `${Dirs.DocumentDir}/${target}` + default: + return target + } + } + + const onWrite = async () => { + try { + setStatus('Writing...') + switch (module) { + case 'rnfs': + await RNFS.writeFile(getPath(), text, 'utf8') + break + case 'file-access': + await FileSystem.writeFile(getPath(), text) + break + case 'async-storage': + await AsyncStorage.setItem(target, text) + break + } + setStatus(`Wrote: "${text}"`) + } catch (e) { + setStatus(`Error: ${(e as Error).message}`) + } + } + + const onRead = async () => { + try { + setStatus('Reading...') + let content: string + switch (module) { + case 'rnfs': + content = await RNFS.readFile(getPath(), 'utf8') + break + case 'file-access': + content = await FileSystem.readFile(getPath()) + break + case 'async-storage': { + const val = await AsyncStorage.getItem(target) + content = val ?? '' + if (!val) { + setStatus(`Key "${target}" not found`) + return + } + break + } + } + setText(content) + setStatus(`Read: "${content}"`) + } catch (e) { + setStatus(`Error: ${(e as Error).message}`) + } + } + + const displayPath = isStorage ? `key: "${target}"` : `Documents/${target}` + + const statusColor = () => { + if (status.includes('BREACH') || status.includes('Error')) return theme.red + if (status.includes('Wrote') || status.includes('Read:')) return theme.green + return theme.textSec + } + + return ( + + + {MODULES.map(m => { + const active = m.key === module + return ( + setModule(m.key)}> + + {m.label} + + + ) + })} + + + + + + + + + Write + + + Read + + + + + {status} + + {displayPath} + + {Platform.OS === 'ios' && ( + + + + + Done + + + + + )} + + ) +} + +const styles = StyleSheet.create({ + root: { + flex: 1, + padding: 12, + }, + segmented: { + flexDirection: 'row', + borderRadius: 8, + padding: 2, + marginBottom: 8, + }, + segItem: { + flex: 1, + paddingVertical: 6, + alignItems: 'center', + borderRadius: 6, + }, + segItemActive: { + ...Platform.select({ + ios: { + shadowColor: '#000', + shadowOffset: {width: 0, height: 1}, + shadowOpacity: 0.12, + shadowRadius: 2, + }, + android: {elevation: 1}, + }), + }, + segText: { + fontSize: 11, + fontWeight: '500', + }, + segTextActive: { + fontWeight: '600', + }, + targetInput: { + borderWidth: 1, + borderRadius: 8, + paddingHorizontal: 10, + paddingVertical: 8, + fontSize: 13, + marginBottom: 6, + }, + contentInput: { + borderWidth: 1, + borderRadius: 8, + padding: 10, + minHeight: 64, + maxHeight: 80, + textAlignVertical: 'top', + fontSize: 14, + }, + buttonRow: { + flexDirection: 'row', + gap: 10, + marginVertical: 8, + }, + btn: { + flex: 1, + paddingVertical: 10, + borderRadius: 8, + alignItems: 'center', + }, + btnText: { + color: '#fff', + fontWeight: '600', + fontSize: 14, + }, + status: { + fontSize: 12, + fontStyle: 'italic', + }, + path: { + fontSize: 10, + fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace', + marginTop: 2, + opacity: 0.7, + }, + accessory: { + flexDirection: 'row', + justifyContent: 'flex-end', + paddingHorizontal: 14, + paddingVertical: 8, + borderTopWidth: StyleSheet.hairlineWidth, + }, + accessoryBtn: { + fontSize: 16, + fontWeight: '600', + }, +}) diff --git a/apps/fs-experiment/README.md b/apps/fs-experiment/README.md index 4f842b2..064e2ef 100644 --- a/apps/fs-experiment/README.md +++ b/apps/fs-experiment/README.md @@ -1,14 +1,8 @@ -# File System Access Example +# File System & Storage Isolation ![Platform: iOS](https://img.shields.io/badge/platform-iOS-blue.svg) -This example demonstrates how to enable file system access in multi-instance environments by whitelisting the necessary native modules. The application shows how sandboxed React Native instances can be configured to access file system APIs when explicitly allowed. - -The experiment uses two popular React Native file system libraries: -- **react-native-fs** - Traditional file system operations -- **react-native-file-access** - Alternative file system API - -The host application creates multiple sandbox instances and demonstrates how to whitelist these modules to enable controlled file system access across instances while maintaining security boundaries. +This example demonstrates **TurboModule substitutions** — transparently replacing native module implementations inside a sandbox with scoped, per-origin alternatives. The app uses a split-screen layout where the host and sandbox run the same UI, but the sandbox can swap `react-native-fs`, `react-native-file-access`, and `@react-native-async-storage/async-storage` for sandboxed implementations that jail file paths and scope storage per origin. ## Screenshot diff --git a/apps/fs-experiment/SandboxFS.tsx b/apps/fs-experiment/SandboxFS.tsx deleted file mode 100644 index 5d7704f..0000000 --- a/apps/fs-experiment/SandboxFS.tsx +++ /dev/null @@ -1,297 +0,0 @@ -import React, {useState} from 'react' -import { - Platform, - SafeAreaView, - ScrollView, - StyleSheet, - Text, - TextInput, - TouchableOpacity, - useColorScheme, - View, -} from 'react-native' -// File system import -import RNFS from 'react-native-fs' - -const SHARED_FILE_PATH = `${RNFS.DocumentDirectoryPath}/shared_test_file.txt` - -function SandboxFS(): React.JSX.Element { - const isDarkMode = useColorScheme() === 'dark' - const [textContent, setTextContent] = useState('') - const [status, setStatus] = useState('Ready') - - const theme = { - background: isDarkMode ? '#000000' : '#ffffff', - surface: isDarkMode ? '#1c1c1e' : '#f2f2f7', - primary: isDarkMode ? '#ff6b35' : '#ff6b35', - secondary: isDarkMode ? '#34c759' : '#34c759', - text: isDarkMode ? '#ffffff' : '#000000', - textSecondary: isDarkMode ? '#8e8e93' : '#3c3c43', - border: isDarkMode ? '#38383a' : '#c6c6c8', - success: '#34c759', - error: '#ff3b30', - warning: '#ff9500', - } - - const writeFile = async () => { - try { - setStatus('Writing file...') - await RNFS.writeFile(SHARED_FILE_PATH, textContent, 'utf8') - setStatus(`Successfully wrote: "${textContent}"`) - } catch (error) { - setStatus(`Write error: ${(error as Error).message}`) - } - } - - const readFile = async () => { - try { - setStatus('Reading file...') - const content = await RNFS.readFile(SHARED_FILE_PATH, 'utf8') - setTextContent(content) - if (content.includes('Host')) { - setStatus(`SECURITY BREACH: Read host file: "${content}"`) - } else { - setStatus(`Successfully read: "${content}"`) - } - } catch (error) { - setStatus(`Read error: ${(error as Error).message}`) - } - } - - const getStatusStyle = () => { - if (status.includes('SECURITY BREACH')) { - return {color: theme.error, fontWeight: '600' as const} - } - if (status.includes('error')) { - return {color: theme.error} - } - if (status.includes('Successfully')) { - return {color: theme.success} - } - return {color: theme.textSecondary} - } - - return ( - - - {/* Header */} - - - - Sandbox Environment - - - RNFS - - - - React Native File System Implementation - - - - - - - File Operations - - - - - - - Write - - - - Read - - - - - - Operation Status: - - - {status} - - - - - - Target Path: - - - {SHARED_FILE_PATH} - - - - - - - ) -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - }, - header: { - paddingHorizontal: 16, - paddingVertical: 20, - ...Platform.select({ - ios: { - shadowColor: '#000', - shadowOffset: {width: 0, height: 1}, - shadowOpacity: 0.1, - shadowRadius: 4, - }, - android: { - elevation: 2, - }, - }), - }, - headerContent: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - marginBottom: 4, - }, - title: { - fontSize: 20, - fontWeight: '700', - flex: 1, - }, - subtitle: { - fontSize: 14, - fontWeight: '400', - }, - badge: { - paddingHorizontal: 8, - paddingVertical: 4, - borderRadius: 6, - }, - badgeText: { - color: '#ffffff', - fontSize: 11, - fontWeight: '600', - textTransform: 'uppercase', - }, - content: { - padding: 16, - }, - card: { - borderRadius: 12, - padding: 20, - borderWidth: 1, - ...Platform.select({ - ios: { - shadowColor: '#000', - shadowOffset: {width: 0, height: 2}, - shadowOpacity: 0.1, - shadowRadius: 8, - }, - android: { - elevation: 3, - }, - }), - }, - sectionTitle: { - fontSize: 16, - fontWeight: '600', - marginBottom: 16, - }, - textInput: { - borderWidth: 1, - borderRadius: 8, - padding: 14, - marginBottom: 16, - minHeight: 80, - textAlignVertical: 'top', - fontSize: 15, - lineHeight: 20, - }, - buttonGroup: { - flexDirection: 'row', - gap: 10, - marginBottom: 16, - }, - button: { - flex: 1, - paddingVertical: 12, - paddingHorizontal: 16, - borderRadius: 8, - alignItems: 'center', - justifyContent: 'center', - }, - buttonText: { - color: '#ffffff', - fontWeight: '600', - fontSize: 15, - }, - statusContainer: { - padding: 12, - borderRadius: 8, - marginBottom: 12, - }, - statusLabel: { - fontSize: 13, - fontWeight: '500', - marginBottom: 4, - }, - statusText: { - fontSize: 13, - fontStyle: 'italic', - lineHeight: 18, - }, - pathContainer: { - padding: 12, - borderRadius: 8, - }, - pathLabel: { - fontSize: 11, - fontWeight: '500', - marginBottom: 4, - textTransform: 'uppercase', - }, - pathText: { - fontSize: 10, - fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace', - opacity: 0.8, - lineHeight: 14, - }, -}) - -export default SandboxFS diff --git a/apps/fs-experiment/SandboxFileAccess.tsx b/apps/fs-experiment/SandboxFileAccess.tsx deleted file mode 100644 index 4a86a82..0000000 --- a/apps/fs-experiment/SandboxFileAccess.tsx +++ /dev/null @@ -1,297 +0,0 @@ -import React, {useState} from 'react' -import { - Platform, - SafeAreaView, - ScrollView, - StyleSheet, - Text, - TextInput, - TouchableOpacity, - useColorScheme, - View, -} from 'react-native' -// File system import -import {Dirs, FileSystem} from 'react-native-file-access' - -const SHARED_FILE_PATH = `${Dirs.DocumentDir}/shared_test_file.txt` - -function SandboxFileAccess(): React.JSX.Element { - const isDarkMode = useColorScheme() === 'dark' - const [textContent, setTextContent] = useState('') - const [status, setStatus] = useState('Ready') - - const theme = { - background: isDarkMode ? '#000000' : '#ffffff', - surface: isDarkMode ? '#1c1c1e' : '#f2f2f7', - primary: isDarkMode ? '#9b59b6' : '#9b59b6', - secondary: isDarkMode ? '#34c759' : '#34c759', - text: isDarkMode ? '#ffffff' : '#000000', - textSecondary: isDarkMode ? '#8e8e93' : '#3c3c43', - border: isDarkMode ? '#38383a' : '#c6c6c8', - success: '#34c759', - error: '#ff3b30', - warning: '#ff9500', - } - - const writeFile = async () => { - try { - setStatus('Writing file...') - await FileSystem.writeFile(SHARED_FILE_PATH, textContent) - setStatus(`Successfully wrote: "${textContent}"`) - } catch (error) { - setStatus(`Write error: ${(error as Error).message}`) - } - } - - const readFile = async () => { - try { - setStatus('Reading file...') - const content = await FileSystem.readFile(SHARED_FILE_PATH) - setTextContent(content) - if (content.includes('Host')) { - setStatus(`SECURITY BREACH: Read host file: "${content}"`) - } else { - setStatus(`Successfully read: "${content}"`) - } - } catch (error) { - setStatus(`Read error: ${(error as Error).message}`) - } - } - - const getStatusStyle = () => { - if (status.includes('SECURITY BREACH')) { - return {color: theme.error, fontWeight: '600' as const} - } - if (status.includes('error')) { - return {color: theme.error} - } - if (status.includes('Successfully')) { - return {color: theme.success} - } - return {color: theme.textSecondary} - } - - return ( - - - {/* Header */} - - - - Sandbox Environment - - - File Access - - - - React Native File Access Implementation - - - - - - - File Operations - - - - - - - Write - - - - Read - - - - - - Operation Status: - - - {status} - - - - - - Target Path: - - - {SHARED_FILE_PATH} - - - - - - - ) -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - }, - header: { - paddingHorizontal: 16, - paddingVertical: 20, - ...Platform.select({ - ios: { - shadowColor: '#000', - shadowOffset: {width: 0, height: 1}, - shadowOpacity: 0.1, - shadowRadius: 4, - }, - android: { - elevation: 2, - }, - }), - }, - headerContent: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - marginBottom: 4, - }, - title: { - fontSize: 20, - fontWeight: '700', - flex: 1, - }, - subtitle: { - fontSize: 14, - fontWeight: '400', - }, - badge: { - paddingHorizontal: 8, - paddingVertical: 4, - borderRadius: 6, - }, - badgeText: { - color: '#ffffff', - fontSize: 11, - fontWeight: '600', - textTransform: 'uppercase', - }, - content: { - padding: 16, - }, - card: { - borderRadius: 12, - padding: 20, - borderWidth: 1, - ...Platform.select({ - ios: { - shadowColor: '#000', - shadowOffset: {width: 0, height: 2}, - shadowOpacity: 0.1, - shadowRadius: 8, - }, - android: { - elevation: 3, - }, - }), - }, - sectionTitle: { - fontSize: 16, - fontWeight: '600', - marginBottom: 16, - }, - textInput: { - borderWidth: 1, - borderRadius: 8, - padding: 14, - marginBottom: 16, - minHeight: 80, - textAlignVertical: 'top', - fontSize: 15, - lineHeight: 20, - }, - buttonGroup: { - flexDirection: 'row', - gap: 10, - marginBottom: 16, - }, - button: { - flex: 1, - paddingVertical: 12, - paddingHorizontal: 16, - borderRadius: 8, - alignItems: 'center', - justifyContent: 'center', - }, - buttonText: { - color: '#ffffff', - fontWeight: '600', - fontSize: 15, - }, - statusContainer: { - padding: 12, - borderRadius: 8, - marginBottom: 12, - }, - statusLabel: { - fontSize: 13, - fontWeight: '500', - marginBottom: 4, - }, - statusText: { - fontSize: 13, - fontStyle: 'italic', - lineHeight: 18, - }, - pathContainer: { - padding: 12, - borderRadius: 8, - }, - pathLabel: { - fontSize: 11, - fontWeight: '500', - marginBottom: 4, - textTransform: 'uppercase', - }, - pathText: { - fontSize: 10, - fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace', - opacity: 0.8, - lineHeight: 14, - }, -}) - -export default SandboxFileAccess diff --git a/apps/fs-experiment/docs/screenshot.png b/apps/fs-experiment/docs/screenshot.png index c777c82..e33eb42 100644 Binary files a/apps/fs-experiment/docs/screenshot.png and b/apps/fs-experiment/docs/screenshot.png differ diff --git a/apps/fs-experiment/ios/AppDelegate.swift b/apps/fs-experiment/ios/AppDelegate.swift index 831c037..3c6f5a5 100644 --- a/apps/fs-experiment/ios/AppDelegate.swift +++ b/apps/fs-experiment/ios/AppDelegate.swift @@ -42,7 +42,7 @@ class ReactNativeDelegate: RCTDefaultReactNativeFactoryDelegate { #if DEBUG RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: "index") #else - Bundle.main.url(forResource: jsBundleName, withExtension: "jsbundle") + Bundle.main.url(forResource: "main", withExtension: "jsbundle") #endif } } diff --git a/apps/fs-experiment/ios/MultInstance-FSExperiment.xcodeproj/project.pbxproj b/apps/fs-experiment/ios/MultInstance-FSExperiment.xcodeproj/project.pbxproj index f0e2763..e6f70cc 100644 --- a/apps/fs-experiment/ios/MultInstance-FSExperiment.xcodeproj/project.pbxproj +++ b/apps/fs-experiment/ios/MultInstance-FSExperiment.xcodeproj/project.pbxproj @@ -8,10 +8,13 @@ /* Begin PBXBuildFile section */ 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; }; + 16A9D245FE0364DDA6A37BB5 /* SandboxedRNCAsyncStorage.mm in Sources */ = {isa = PBXBuildFile; fileRef = 73D74517051F649BF3AF385E /* SandboxedRNCAsyncStorage.mm */; }; 43DD6316E596C4F3419573F4 /* libPods-MultInstance-FSExperiment.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 6BD4A3E0B4C109F8DD0FAE9D /* libPods-MultInstance-FSExperiment.a */; }; 575209B0052EDA94007D9B65 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */; }; 761780ED2CA45674006654EE /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 761780EC2CA45674006654EE /* AppDelegate.swift */; }; 81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */; }; + A1B2C3D4E5F60001DEADBEEF /* SandboxedRNFSManager.mm in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60002DEADBEEF /* SandboxedRNFSManager.mm */; }; + A1B2C3D4E5F60003DEADBEEF /* SandboxedFileAccess.mm in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60004DEADBEEF /* SandboxedFileAccess.mm */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -23,8 +26,14 @@ 4144D1AD685F86583FAB67C6 /* Pods-MultInstance-FSExperiment.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MultInstance-FSExperiment.release.xcconfig"; path = "Target Support Files/Pods-MultInstance-FSExperiment/Pods-MultInstance-FSExperiment.release.xcconfig"; sourceTree = ""; }; 5709B34CF0A7D63546082F79 /* Pods-MultInstance-FSExperiment.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MultInstance-FSExperiment.release.xcconfig"; path = "Target Support Files/Pods-MultInstance-FSExperiment/Pods-MultInstance-FSExperiment.release.xcconfig"; sourceTree = ""; }; 6BD4A3E0B4C109F8DD0FAE9D /* libPods-MultInstance-FSExperiment.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-MultInstance-FSExperiment.a"; sourceTree = BUILT_PRODUCTS_DIR; }; + 73D74517051F649BF3AF385E /* SandboxedRNCAsyncStorage.mm */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.cpp.objcpp; path = SandboxedRNCAsyncStorage.mm; sourceTree = ""; }; 761780EC2CA45674006654EE /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = LaunchScreen.storyboard; path = "MultInstance-FSExperiment/LaunchScreen.storyboard"; sourceTree = ""; }; + A1B2C3D4E5F60002DEADBEEF /* SandboxedRNFSManager.mm */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.cpp.objcpp; path = SandboxedRNFSManager.mm; sourceTree = ""; }; + A1B2C3D4E5F60004DEADBEEF /* SandboxedFileAccess.mm */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.cpp.objcpp; path = SandboxedFileAccess.mm; sourceTree = ""; }; + A1B2C3D4E5F60005DEADBEEF /* SandboxedRNFSManager.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = SandboxedRNFSManager.h; sourceTree = ""; }; + A1B2C3D4E5F60006DEADBEEF /* SandboxedFileAccess.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = SandboxedFileAccess.h; sourceTree = ""; }; + B8F0D39C18571332952E64A6 /* SandboxedRNCAsyncStorage.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = SandboxedRNCAsyncStorage.h; sourceTree = ""; }; E4C18DFA4C4A23A5EFB8579E /* Pods-MultInstance-FSExperiment.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MultInstance-FSExperiment.debug.xcconfig"; path = "Target Support Files/Pods-MultInstance-FSExperiment/Pods-MultInstance-FSExperiment.debug.xcconfig"; sourceTree = ""; }; ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; }; /* End PBXFileReference section */ @@ -49,6 +58,12 @@ 13B07FB61A68108700A75B9A /* Info.plist */, 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */, 13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */, + B8F0D39C18571332952E64A6 /* SandboxedRNCAsyncStorage.h */, + 73D74517051F649BF3AF385E /* SandboxedRNCAsyncStorage.mm */, + A1B2C3D4E5F60005DEADBEEF /* SandboxedRNFSManager.h */, + A1B2C3D4E5F60002DEADBEEF /* SandboxedRNFSManager.mm */, + A1B2C3D4E5F60006DEADBEEF /* SandboxedFileAccess.h */, + A1B2C3D4E5F60004DEADBEEF /* SandboxedFileAccess.mm */, ); name = "MultInstance-FSExperiment"; sourceTree = ""; @@ -251,6 +266,9 @@ buildActionMask = 2147483647; files = ( 761780ED2CA45674006654EE /* AppDelegate.swift in Sources */, + 16A9D245FE0364DDA6A37BB5 /* SandboxedRNCAsyncStorage.mm in Sources */, + A1B2C3D4E5F60001DEADBEEF /* SandboxedRNFSManager.mm in Sources */, + A1B2C3D4E5F60003DEADBEEF /* SandboxedFileAccess.mm in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/apps/fs-experiment/ios/Podfile.lock b/apps/fs-experiment/ios/Podfile.lock index 607299b..3fd4cf4 100644 --- a/apps/fs-experiment/ios/Podfile.lock +++ b/apps/fs-experiment/ios/Podfile.lock @@ -2019,7 +2019,7 @@ PODS: - React-timing - React-utils - SocketRocket - - React-Sandbox (0.4.0): + - React-Sandbox (0.4.1): - boost - DoubleConversion - fast_float @@ -2181,6 +2181,35 @@ PODS: - SocketRocket - Yoga - ZIPFoundation + - RNCAsyncStorage (2.2.0): + - boost + - DoubleConversion + - fast_float + - fmt + - glog + - hermes-engine + - RCT-Folly + - RCT-Folly/Fabric + - RCTRequired + - RCTTypeSafety + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - SocketRocket + - Yoga - RNFS (2.20.0): - React-Core - SocketRocket (0.7.1) @@ -2262,6 +2291,7 @@ DEPENDENCIES: - ReactCodegen (from `build/generated/ios`) - ReactCommon/turbomodule/core (from `../../../node_modules/react-native/ReactCommon`) - ReactNativeFileAccess (from `../../../node_modules/react-native-file-access`) + - "RNCAsyncStorage (from `../../../node_modules/@react-native-async-storage/async-storage`)" - RNFS (from `../../../node_modules/react-native-fs`) - SocketRocket (~> 0.7.1) - Yoga (from `../../../node_modules/react-native/ReactCommon/yoga`) @@ -2419,6 +2449,8 @@ EXTERNAL SOURCES: :path: "../../../node_modules/react-native/ReactCommon" ReactNativeFileAccess: :path: "../../../node_modules/react-native-file-access" + RNCAsyncStorage: + :path: "../../../node_modules/@react-native-async-storage/async-storage" RNFS: :path: "../../../node_modules/react-native-fs" Yoga: @@ -2491,13 +2523,14 @@ SPEC CHECKSUMS: React-runtimeexecutor: 17c70842d5e611130cb66f91e247bc4a609c3508 React-RuntimeHermes: 3c88e6e1ea7ea0899dcffc77c10d61ea46688cfd React-runtimescheduler: 024500621c7c93d65371498abb4ee26d34f5d47d - React-Sandbox: e3cf3c955559ed9f0bf014b29dce1e94600cd790 + React-Sandbox: 9c091813e335735668c62b2d3dbeb1456f93d8a5 React-timing: c3c923df2b86194e1682e01167717481232f1dc7 React-utils: 9154a037543147e1c24098f1a48fc8472602c092 ReactAppDependencyProvider: afd905e84ee36e1678016ae04d7370c75ed539be ReactCodegen: 06bf9ae2e01a2416250cf5e44e4a06b1c9ea201b ReactCommon: 17fd88849a174bf9ce45461912291aca711410fc ReactNativeFileAccess: f63160ff4e203afa99e04d9215c2ab946748b9e0 + RNCAsyncStorage: 1f04c8d56558e533277beda29187f571cf7eecb2 RNFS: 89de7d7f4c0f6bafa05343c578f61118c8282ed8 SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 Yoga: daa1e4de4b971b977b23bc842aaa3e135324f1f3 diff --git a/apps/fs-experiment/ios/SandboxedFileAccess.h b/apps/fs-experiment/ios/SandboxedFileAccess.h new file mode 100644 index 0000000..e6950ab --- /dev/null +++ b/apps/fs-experiment/ios/SandboxedFileAccess.h @@ -0,0 +1,24 @@ +/** + * Sandboxed FileAccess implementation for react-native-sandbox. + * + * Wraps the react-native-file-access module interface, scoping all file + * operations to a per-origin sandbox directory. Constants (DocumentDir, + * CacheDir, etc.) are overridden to point into the sandbox root. + */ + +#import +#import + +#ifdef RCT_NEW_ARCH_ENABLED +#import + +@interface SandboxedFileAccess : RCTEventEmitter +#else +#import + +@interface SandboxedFileAccess : RCTEventEmitter +#endif + +@property (nonatomic, copy) NSString *sandboxRoot; + +@end diff --git a/apps/fs-experiment/ios/SandboxedFileAccess.mm b/apps/fs-experiment/ios/SandboxedFileAccess.mm new file mode 100644 index 0000000..c1b9773 --- /dev/null +++ b/apps/fs-experiment/ios/SandboxedFileAccess.mm @@ -0,0 +1,630 @@ +/** + * Sandboxed FileAccess — jails all file paths to a per-origin directory. + * + * Implements the NativeFileAccessSpec interface so JS code using + * react-native-file-access works transparently inside a sandbox. + */ + +#import "SandboxedFileAccess.h" + +#import +#import +#import + +#ifdef RCT_NEW_ARCH_ENABLED +#import +#endif + +@implementation SandboxedFileAccess { + NSString *_documentsDir; + NSString *_cachesDir; + NSString *_libraryDir; + BOOL _configured; +} + +RCT_EXPORT_MODULE(SandboxedFileAccess) + ++ (BOOL)requiresMainQueueSetup { return NO; } + +- (NSArray *)supportedEvents +{ + return @[@"FetchResult"]; +} + +#pragma mark - Sandbox setup + +- (void)_setupDirectoriesForOrigin:(NSString *)origin +{ + NSString *appSupport = NSSearchPathForDirectoriesInDomains( + NSApplicationSupportDirectory, NSUserDomainMask, YES).firstObject; + NSString *bundleId = [[NSBundle mainBundle] bundleIdentifier] ?: @"com.unknown"; + _sandboxRoot = [[[appSupport stringByAppendingPathComponent:bundleId] + stringByAppendingPathComponent:@"Sandboxes"] + stringByAppendingPathComponent:origin]; + + _documentsDir = [_sandboxRoot stringByAppendingPathComponent:@"Documents"]; + _cachesDir = [_sandboxRoot stringByAppendingPathComponent:@"Caches"]; + _libraryDir = [_sandboxRoot stringByAppendingPathComponent:@"Library"]; + + NSFileManager *fm = [NSFileManager defaultManager]; + for (NSString *dir in @[_documentsDir, _cachesDir, _libraryDir]) { + [fm createDirectoryAtPath:dir withIntermediateDirectories:YES attributes:nil error:nil]; + } + + _configured = YES; +} + +#pragma mark - RCTSandboxAwareModule + +- (void)configureSandboxWithOrigin:(NSString *)origin + requestedName:(NSString *)requestedName + resolvedName:(NSString *)resolvedName +{ + NSLog(@"[SandboxedFileAccess] Configuring for origin '%@'", origin); + [self _setupDirectoriesForOrigin:origin]; +} + +#pragma mark - Path validation + +- (nullable NSString *)_sandboxedPath:(NSString *)path + reject:(RCTPromiseRejectBlock)reject +{ + if (!_configured) { + reject(@"EPERM", @"SandboxedFileAccess: sandbox not configured. " + "configureSandboxWithOrigin: must be called before any file operation.", nil); + return nil; + } + + NSString *resolved; + if ([path hasPrefix:@"/"]) { + resolved = [path stringByStandardizingPath]; + } else { + resolved = [[_documentsDir stringByAppendingPathComponent:path] stringByStandardizingPath]; + } + + if ([resolved hasPrefix:_sandboxRoot]) { + return resolved; + } + + reject(@"EPERM", [NSString stringWithFormat: + @"Path '%@' is outside the sandbox. Allowed root: %@", path, _sandboxRoot], nil); + return nil; +} + +#pragma mark - Constants + +#ifdef RCT_NEW_ARCH_ENABLED +- (facebook::react::ModuleConstants)constantsToExport +{ + return [self getConstants]; +} + +- (facebook::react::ModuleConstants)getConstants +{ + if (!_configured) { + return facebook::react::typedConstants({ + .CacheDir = @"", + .DocumentDir = @"", + .LibraryDir = @"", + .MainBundleDir = @"", + }); + } + return facebook::react::typedConstants({ + .CacheDir = _cachesDir, + .DocumentDir = _documentsDir, + .LibraryDir = _libraryDir, + .MainBundleDir = _documentsDir, + }); +} + +- (std::shared_ptr)getTurboModule: + (const facebook::react::ObjCTurboModule::InitParams &)params +{ + return std::make_shared(params); +} +#else +- (NSDictionary *)constantsToExport +{ + if (!_configured) { + return @{}; + } + return @{ + @"CacheDir": _cachesDir, + @"DocumentDir": _documentsDir, + @"LibraryDir": _libraryDir, + @"MainBundleDir": _documentsDir, + }; +} +#endif + +#pragma mark - File operations + +RCT_EXPORT_METHOD(writeFile:(NSString *)path + data:(NSString *)data + encoding:(NSString *)encoding + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject) +{ + NSString *safePath = [self _sandboxedPath:path reject:reject]; + if (!safePath) return; + + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + NSError *error; + if ([encoding isEqualToString:@"base64"]) { + NSData *decoded = [[NSData alloc] initWithBase64EncodedString:data + options:NSDataBase64DecodingIgnoreUnknownCharacters]; + if (!decoded) { + reject(@"ERR", [NSString stringWithFormat:@"Failed to write to '%@', invalid base64.", path], nil); + return; + } + [decoded writeToFile:safePath options:NSDataWritingAtomic error:&error]; + } else { + [data writeToFile:safePath atomically:NO encoding:NSUTF8StringEncoding error:&error]; + } + if (error) { + reject(@"ERR", [NSString stringWithFormat:@"Failed to write to '%@'. %@", path, error.localizedDescription], error); + } else { + resolve(nil); + } + }); +} + +RCT_EXPORT_METHOD(readFile:(NSString *)path + encoding:(NSString *)encoding + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject) +{ + NSString *safePath = [self _sandboxedPath:path reject:reject]; + if (!safePath) return; + + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + NSError *error; + if ([encoding isEqualToString:@"base64"]) { + NSData *data = [NSData dataWithContentsOfFile:safePath options:0 error:&error]; + if (error || !data) { + reject(@"ERR", [NSString stringWithFormat:@"Failed to read '%@'. %@", path, + error.localizedDescription ?: @""], error); + return; + } + resolve([data base64EncodedStringWithOptions:0]); + } else { + NSString *content = [NSString stringWithContentsOfFile:safePath encoding:NSUTF8StringEncoding error:&error]; + if (error) { + reject(@"ERR", [NSString stringWithFormat:@"Failed to read '%@'. %@", path, error.localizedDescription], error); + return; + } + resolve(content); + } + }); +} + +RCT_EXPORT_METHOD(readFileChunk:(NSString *)path + offset:(double)offset + length:(double)length + encoding:(NSString *)encoding + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject) +{ + NSString *safePath = [self _sandboxedPath:path reject:reject]; + if (!safePath) return; + + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + NSError *error; + NSFileHandle *fh = [NSFileHandle fileHandleForReadingFromURL: + [NSURL fileURLWithPath:safePath] error:&error]; + if (error || !fh) { + reject(@"ERR", [NSString stringWithFormat:@"Failed to read '%@'. %@", path, + error.localizedDescription ?: @""], error); + return; + } + + [fh seekToFileOffset:(unsigned long long)offset]; + NSData *data = [fh readDataOfLength:(NSUInteger)length]; + [fh closeFile]; + + if ([encoding isEqualToString:@"base64"]) { + resolve([data base64EncodedStringWithOptions:0]); + } else { + NSString *content = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; + if (content) { + resolve(content); + } else { + reject(@"ERR", @"Failed to decode content with specified encoding.", nil); + } + } + }); +} + +RCT_EXPORT_METHOD(appendFile:(NSString *)path + data:(NSString *)data + encoding:(NSString *)encoding + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject) +{ + NSString *safePath = [self _sandboxedPath:path reject:reject]; + if (!safePath) return; + + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + NSData *encoded = [encoding isEqualToString:@"base64"] + ? [[NSData alloc] initWithBase64EncodedString:data options:NSDataBase64DecodingIgnoreUnknownCharacters] + : [data dataUsingEncoding:NSUTF8StringEncoding]; + + NSFileHandle *fh = [NSFileHandle fileHandleForWritingAtPath:safePath]; + if (!fh) { + reject(@"ERR", [NSString stringWithFormat:@"Failed to append to '%@'.", path], nil); + return; + } + [fh seekToEndOfFile]; + [fh writeData:encoded]; + [fh closeFile]; + resolve(nil); + }); +} + +RCT_EXPORT_METHOD(exists:(NSString *)path + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject) +{ + NSString *safePath = [self _sandboxedPath:path reject:reject]; + if (!safePath) return; + + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + resolve(@([[NSFileManager defaultManager] fileExistsAtPath:safePath])); + }); +} + +RCT_EXPORT_METHOD(isDir:(NSString *)path + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject) +{ + NSString *safePath = [self _sandboxedPath:path reject:reject]; + if (!safePath) return; + + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + BOOL isDir = NO; + BOOL exists = [[NSFileManager defaultManager] fileExistsAtPath:safePath isDirectory:&isDir]; + resolve(@(exists && isDir)); + }); +} + +RCT_EXPORT_METHOD(ls:(NSString *)path + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject) +{ + NSString *safePath = [self _sandboxedPath:path reject:reject]; + if (!safePath) return; + + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + NSError *error; + NSArray *contents = [[NSFileManager defaultManager] contentsOfDirectoryAtPath:safePath error:&error]; + if (error) { + reject(@"ERR", [NSString stringWithFormat:@"Failed to list '%@'. %@", path, error.localizedDescription], error); + return; + } + resolve(contents); + }); +} + +RCT_EXPORT_METHOD(mkdir:(NSString *)path + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject) +{ + NSString *safePath = [self _sandboxedPath:path reject:reject]; + if (!safePath) return; + + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + NSError *error; + if (![[NSFileManager defaultManager] createDirectoryAtPath:safePath + withIntermediateDirectories:YES + attributes:nil + error:&error]) { + reject(@"ERR", [NSString stringWithFormat:@"Failed to create '%@'. %@", path, error.localizedDescription], error); + return; + } + resolve(safePath); + }); +} + +RCT_EXPORT_METHOD(cp:(NSString *)source + target:(NSString *)target + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject) +{ + NSString *src = [self _sandboxedPath:source reject:reject]; + if (!src) return; + NSString *dst = [self _sandboxedPath:target reject:reject]; + if (!dst) return; + + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + NSError *error; + if (![[NSFileManager defaultManager] copyItemAtPath:src toPath:dst error:&error]) { + reject(@"ERR", [NSString stringWithFormat:@"Failed to copy '%@' to '%@'. %@", + source, target, error.localizedDescription], error); + return; + } + resolve(nil); + }); +} + +RCT_EXPORT_METHOD(mv:(NSString *)source + target:(NSString *)target + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject) +{ + NSString *src = [self _sandboxedPath:source reject:reject]; + if (!src) return; + NSString *dst = [self _sandboxedPath:target reject:reject]; + if (!dst) return; + + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + NSError *error; + [[NSFileManager defaultManager] removeItemAtPath:dst error:nil]; + if (![[NSFileManager defaultManager] moveItemAtPath:src toPath:dst error:&error]) { + reject(@"ERR", [NSString stringWithFormat:@"Failed to move '%@' to '%@'. %@", + source, target, error.localizedDescription], error); + return; + } + resolve(nil); + }); +} + +RCT_EXPORT_METHOD(unlink:(NSString *)path + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject) +{ + NSString *safePath = [self _sandboxedPath:path reject:reject]; + if (!safePath) return; + + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + NSError *error; + if (![[NSFileManager defaultManager] removeItemAtPath:safePath error:&error]) { + reject(@"ERR", [NSString stringWithFormat:@"Failed to unlink '%@'. %@", path, error.localizedDescription], error); + return; + } + resolve(nil); + }); +} + +RCT_EXPORT_METHOD(stat:(NSString *)path + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject) +{ + NSString *safePath = [self _sandboxedPath:path reject:reject]; + if (!safePath) return; + + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + NSError *error; + NSDictionary *attrs = [[NSFileManager defaultManager] attributesOfItemAtPath:safePath error:&error]; + if (error) { + reject(@"ERR", [NSString stringWithFormat:@"Failed to stat '%@'. %@", path, error.localizedDescription], error); + return; + } + + NSURL *pathUrl = [NSURL fileURLWithPath:safePath]; + BOOL isDir = NO; + [[NSFileManager defaultManager] fileExistsAtPath:safePath isDirectory:&isDir]; + + resolve(@{ + @"filename": pathUrl.lastPathComponent ?: @"", + @"lastModified": @(1000.0 * [(NSDate *)attrs[NSFileModificationDate] timeIntervalSince1970]), + @"path": safePath, + @"size": attrs[NSFileSize] ?: @0, + @"type": isDir ? @"directory" : @"file", + }); + }); +} + +RCT_EXPORT_METHOD(statDir:(NSString *)path + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject) +{ + NSString *safePath = [self _sandboxedPath:path reject:reject]; + if (!safePath) return; + + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + NSError *error; + NSArray *contents = [[NSFileManager defaultManager] contentsOfDirectoryAtPath:safePath error:&error]; + if (error) { + reject(@"ERR", [NSString stringWithFormat:@"Failed to list '%@'. %@", path, error.localizedDescription], error); + return; + } + + NSMutableArray *results = [NSMutableArray new]; + for (NSString *name in contents) { + NSString *fullPath = [safePath stringByAppendingPathComponent:name]; + NSDictionary *attrs = [[NSFileManager defaultManager] attributesOfItemAtPath:fullPath error:nil]; + if (!attrs) continue; + + BOOL isDir = NO; + [[NSFileManager defaultManager] fileExistsAtPath:fullPath isDirectory:&isDir]; + + [results addObject:@{ + @"filename": name, + @"lastModified": @(1000.0 * [(NSDate *)attrs[NSFileModificationDate] timeIntervalSince1970]), + @"path": fullPath, + @"size": attrs[NSFileSize] ?: @0, + @"type": isDir ? @"directory" : @"file", + }]; + } + resolve(results); + }); +} + +RCT_EXPORT_METHOD(hash:(NSString *)path + algorithm:(NSString *)algorithm + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject) +{ + NSString *safePath = [self _sandboxedPath:path reject:reject]; + if (!safePath) return; + + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + NSData *data = [NSData dataWithContentsOfFile:safePath]; + if (!data) { + reject(@"ERR", [NSString stringWithFormat:@"Failed to read '%@'.", path], nil); + return; + } + + unsigned char buffer[CC_SHA512_DIGEST_LENGTH]; + int digestLength; + + if ([algorithm isEqualToString:@"MD5"]) { + digestLength = CC_MD5_DIGEST_LENGTH; + CC_MD5(data.bytes, (CC_LONG)data.length, buffer); + } else if ([algorithm isEqualToString:@"SHA-1"]) { + digestLength = CC_SHA1_DIGEST_LENGTH; + CC_SHA1(data.bytes, (CC_LONG)data.length, buffer); + } else if ([algorithm isEqualToString:@"SHA-256"]) { + digestLength = CC_SHA256_DIGEST_LENGTH; + CC_SHA256(data.bytes, (CC_LONG)data.length, buffer); + } else if ([algorithm isEqualToString:@"SHA-512"]) { + digestLength = CC_SHA512_DIGEST_LENGTH; + CC_SHA512(data.bytes, (CC_LONG)data.length, buffer); + } else { + reject(@"ERR", [NSString stringWithFormat:@"Unknown algorithm '%@'.", algorithm], nil); + return; + } + + NSMutableString *output = [NSMutableString stringWithCapacity:digestLength * 2]; + for (int i = 0; i < digestLength; i++) { + [output appendFormat:@"%02x", buffer[i]]; + } + resolve(output); + }); +} + +RCT_EXPORT_METHOD(concatFiles:(NSString *)source + target:(NSString *)target + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject) +{ + NSString *src = [self _sandboxedPath:source reject:reject]; + if (!src) return; + NSString *dst = [self _sandboxedPath:target reject:reject]; + if (!dst) return; + + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + NSInputStream *input = [NSInputStream inputStreamWithFileAtPath:src]; + NSOutputStream *output = [NSOutputStream outputStreamToFileAtPath:dst append:YES]; + if (!input || !output) { + reject(@"ERR", [NSString stringWithFormat:@"Failed to concat '%@' to '%@'.", source, target], nil); + return; + } + + [input open]; + [output open]; + NSInteger totalBytes = 0; + uint8_t buf[8192]; + NSInteger len; + while ((len = [input read:buf maxLength:sizeof(buf)]) > 0) { + [output write:buf maxLength:len]; + totalBytes += len; + } + [output close]; + [input close]; + resolve(@(totalBytes)); + }); +} + +RCT_EXPORT_METHOD(df:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject) +{ + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + NSError *error; + NSDictionary *attrs = [[NSFileManager defaultManager] + attributesOfFileSystemForPath:self->_sandboxRoot error:&error]; + if (error) { + reject(@"ERR", [NSString stringWithFormat:@"Failed to stat filesystem. %@", error.localizedDescription], error); + return; + } + resolve(@{ + @"internal_free": attrs[NSFileSystemFreeSize], + @"internal_total": attrs[NSFileSystemSize], + }); + }); +} + +#pragma mark - Blocked operations + +#ifdef RCT_NEW_ARCH_ENABLED +RCT_EXPORT_METHOD(fetch:(double)requestId + resource:(NSString *)resource + init:(JS::NativeFileAccess::SpecFetchInit &)init) +{ + RCTLogWarn(@"[SandboxedFileAccess] fetch is not available in sandboxed mode"); +} +#else +RCT_EXPORT_METHOD(fetch:(double)requestId + resource:(NSString *)resource + init:(NSDictionary *)init) +{ + RCTLogWarn(@"[SandboxedFileAccess] fetch is not available in sandboxed mode"); +} +#endif + +RCT_EXPORT_METHOD(cancelFetch:(double)requestId + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject) +{ + resolve(nil); +} + +RCT_EXPORT_METHOD(cpAsset:(NSString *)asset + target:(NSString *)target + type:(NSString *)type + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject) +{ + reject(@"EPERM", @"cpAsset is not available in sandboxed mode", nil); +} + +RCT_EXPORT_METHOD(cpExternal:(NSString *)source + targetName:(NSString *)targetName + dir:(NSString *)dir + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject) +{ + reject(@"EPERM", @"cpExternal is not available in sandboxed mode", nil); +} + +RCT_EXPORT_METHOD(getAppGroupDir:(NSString *)groupName + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject) +{ + reject(@"EPERM", @"getAppGroupDir is not available in sandboxed mode", nil); +} + +RCT_EXPORT_METHOD(unzip:(NSString *)source + target:(NSString *)target + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject) +{ + NSString *src = [self _sandboxedPath:source reject:reject]; + if (!src) return; + NSString *dst = [self _sandboxedPath:target reject:reject]; + if (!dst) return; + + reject(@"EPERM", @"unzip is not available in sandboxed mode", nil); +} + +RCT_EXPORT_METHOD(hardlink:(NSString *)source + target:(NSString *)target + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject) +{ + reject(@"EPERM", @"hardlink is not available in sandboxed mode", nil); +} + +RCT_EXPORT_METHOD(symlink:(NSString *)source + target:(NSString *)target + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject) +{ + reject(@"EPERM", @"symlink is not available in sandboxed mode", nil); +} + +// Required by RCTEventEmitter +RCT_EXPORT_METHOD(addListener:(NSString *)eventName) {} +RCT_EXPORT_METHOD(removeListeners:(double)count) {} + +@end diff --git a/apps/fs-experiment/ios/SandboxedRNCAsyncStorage.h b/apps/fs-experiment/ios/SandboxedRNCAsyncStorage.h new file mode 100644 index 0000000..30ea798 --- /dev/null +++ b/apps/fs-experiment/ios/SandboxedRNCAsyncStorage.h @@ -0,0 +1,56 @@ +/** + * Sandboxed AsyncStorage implementation for react-native-sandbox. + * + * Based on RNCAsyncStorage from @react-native-async-storage/async-storage, + * adapted to scope storage per sandbox origin. This module is intended to be + * used as a TurboModule substitution target via turboModuleSubstitutions. + * + * When the sandbox requests "RNCAsyncStorage", this module can be resolved + * instead, providing isolated key-value storage per sandbox origin. + */ + +#import + +#import +#import + +#ifdef RCT_NEW_ARCH_ENABLED +#import +#endif + +#import "RNCAsyncStorageDelegate.h" +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface SandboxedRNCAsyncStorage : NSObject < +#ifdef RCT_NEW_ARCH_ENABLED + NativeAsyncStorageModuleSpec +#else + RCTBridgeModule +#endif + , + RCTInvalidating, + RCTSandboxAwareModule> + +@property (nonatomic, weak, nullable) id delegate; +@property (nonatomic, assign) BOOL clearOnInvalidate; +@property (nonatomic, readonly, getter=isValid) BOOL valid; + +/** + * The storage directory for this instance. When created via default init, + * this defaults to a "SandboxedAsyncStorage/default" directory. + * The sandbox delegate's configureSandbox will update the storageDirectory + * based on the sandbox origin BEFORE any storage operations are performed. + */ +@property (nonatomic, copy) NSString *storageDirectory; + +- (instancetype)initWithStorageDirectory:(NSString *)storageDirectory; +- (void)clearAllData; +- (void)multiGet:(NSArray *)keys callback:(RCTResponseSenderBlock)callback; +- (void)multiSet:(NSArray *> *)kvPairs callback:(RCTResponseSenderBlock)callback; +- (void)getAllKeys:(RCTResponseSenderBlock)callback; + +@end + +NS_ASSUME_NONNULL_END diff --git a/apps/fs-experiment/ios/SandboxedRNCAsyncStorage.mm b/apps/fs-experiment/ios/SandboxedRNCAsyncStorage.mm new file mode 100644 index 0000000..91b4653 --- /dev/null +++ b/apps/fs-experiment/ios/SandboxedRNCAsyncStorage.mm @@ -0,0 +1,621 @@ +/** + * Sandboxed AsyncStorage implementation for react-native-sandbox. + * + * Based on the original RNCAsyncStorage from @react-native-async-storage/async-storage. + * Scopes all storage to a per-origin directory to prevent data leaks between sandboxes. + */ + +#import "SandboxedRNCAsyncStorage.h" + +#import +#import +#import + +static NSString *const RCTManifestFileName = @"manifest.json"; +static const NSUInteger RCTInlineValueThreshold = 1024; + +#pragma mark - Static helper functions + +static NSDictionary *RCTErrorForKey(NSString *key) +{ + if (![key isKindOfClass:[NSString class]]) { + return RCTMakeAndLogError(@"Invalid key - must be a string. Key: ", key, @{@"key": key}); + } else if (key.length < 1) { + return RCTMakeAndLogError( + @"Invalid key - must be at least one character. Key: ", key, @{@"key": key}); + } else { + return nil; + } +} + +static void RCTAppendError(NSDictionary *error, NSMutableArray **errors) +{ + if (error && errors) { + if (!*errors) { + *errors = [NSMutableArray new]; + } + [*errors addObject:error]; + } +} + +static NSArray *RCTMakeErrors(NSArray> *results) +{ + NSMutableArray *errors; + for (id object in results) { + if ([object isKindOfClass:[NSError class]]) { + NSError *error = (NSError *)object; + NSDictionary *keyError = RCTMakeError(error.localizedDescription, error, nil); + RCTAppendError(keyError, &errors); + } + } + return errors; +} + +static NSString *RCTReadFile(NSString *filePath, NSString *key, NSDictionary **errorOut) +{ + if ([[NSFileManager defaultManager] fileExistsAtPath:filePath]) { + NSError *error; + NSStringEncoding encoding; + NSString *entryString = [NSString stringWithContentsOfFile:filePath + usedEncoding:&encoding + error:&error]; + NSDictionary *extraData = @{@"key": RCTNullIfNil(key)}; + + if (error) { + if (errorOut) { + *errorOut = RCTMakeError(@"Failed to read storage file.", error, extraData); + } + return nil; + } + + if (encoding != NSUTF8StringEncoding) { + if (errorOut) { + *errorOut = + RCTMakeError(@"Incorrect encoding of storage file: ", @(encoding), extraData); + } + return nil; + } + return entryString; + } + + return nil; +} + +static BOOL RCTMergeRecursive(NSMutableDictionary *destination, NSDictionary *source) +{ + BOOL modified = NO; + for (NSString *key in source) { + id sourceValue = source[key]; + id destinationValue = destination[key]; + if ([sourceValue isKindOfClass:[NSDictionary class]]) { + if ([destinationValue isKindOfClass:[NSDictionary class]]) { + if ([destinationValue classForCoder] != [NSMutableDictionary class]) { + destinationValue = [destinationValue mutableCopy]; + } + if (RCTMergeRecursive(destinationValue, sourceValue)) { + destination[key] = destinationValue; + modified = YES; + } + } else { + destination[key] = [sourceValue copy]; + modified = YES; + } + } else if (![source isEqual:destinationValue]) { + destination[key] = [sourceValue copy]; + modified = YES; + } + } + return modified; +} + +#define RCTGetStorageDirectory() _storageDirectory +#define RCTGetManifestFilePath() _manifestFilePath +#define RCTGetMethodQueue() self.methodQueue +#define RCTGetCache() self.cache + +static NSDictionary *RCTDeleteStorageDirectory(NSString *storageDirectory) +{ + NSError *error; + [[NSFileManager defaultManager] removeItemAtPath:storageDirectory error:&error]; + return error ? RCTMakeError(@"Failed to delete storage directory.", error, nil) : nil; +} + +#define RCTDeleteStorageDirectory() RCTDeleteStorageDirectory(_storageDirectory) + +#pragma mark - SandboxedRNCAsyncStorage + +@interface SandboxedRNCAsyncStorage () + +@property (nonatomic, copy) NSString *manifestFilePath; + +@end + +@implementation SandboxedRNCAsyncStorage { + BOOL _haveSetup; + BOOL _configured; + NSMutableDictionary *_manifest; + NSCache *_cache; + dispatch_once_t _cacheOnceToken; +} + +RCT_EXPORT_MODULE(SandboxedAsyncStorage) + +- (instancetype)initWithStorageDirectory:(NSString *)storageDirectory +{ + if ((self = [super init])) { + _storageDirectory = storageDirectory; + _manifestFilePath = [_storageDirectory stringByAppendingPathComponent:RCTManifestFileName]; + _configured = YES; + } + return self; +} + +@synthesize methodQueue = _methodQueue; + +- (void)setStorageDirectory:(NSString *)storageDirectory +{ + _storageDirectory = [storageDirectory copy]; + _manifestFilePath = [_storageDirectory stringByAppendingPathComponent:RCTManifestFileName]; + _haveSetup = NO; + [_manifest removeAllObjects]; + [_cache removeAllObjects]; +} + +- (NSCache *)cache +{ + dispatch_once(&_cacheOnceToken, ^{ + _cache = [NSCache new]; + _cache.totalCostLimit = 2 * 1024 * 1024; // 2MB + + [[NSNotificationCenter defaultCenter] + addObserverForName:UIApplicationDidReceiveMemoryWarningNotification + object:nil + queue:nil + usingBlock:^(__unused NSNotification *note) { + [self->_cache removeAllObjects]; + }]; + }); + return _cache; +} + ++ (BOOL)requiresMainQueueSetup +{ + return NO; +} + +- (instancetype)init +{ + if ((self = [super init])) { + _configured = NO; + } + return self; +} + +- (void)clearAllData +{ + dispatch_async(RCTGetMethodQueue(), ^{ + [self->_manifest removeAllObjects]; + [RCTGetCache() removeAllObjects]; + RCTDeleteStorageDirectory(); + }); +} + +- (void)invalidate +{ + if (_clearOnInvalidate) { + [RCTGetCache() removeAllObjects]; + RCTDeleteStorageDirectory(); + } + _clearOnInvalidate = NO; + [_manifest removeAllObjects]; + _haveSetup = NO; +} + +- (BOOL)isValid +{ + return _haveSetup; +} + +- (void)dealloc +{ + [self invalidate]; +} + +- (NSString *)_filePathForKey:(NSString *)key +{ + NSString *safeFileName = RCTMD5Hash(key); + return [RCTGetStorageDirectory() stringByAppendingPathComponent:safeFileName]; +} + +- (NSDictionary *)_ensureSetup +{ + RCTAssertThread(RCTGetMethodQueue(), @"Must be executed on storage thread"); + + if (!_configured) { + return RCTMakeError(@"SandboxedAsyncStorage: sandbox not configured. " + "configureSandboxWithOrigin: must be called before any storage operation.", nil, nil); + } + + NSError *error = nil; + [[NSFileManager defaultManager] createDirectoryAtPath:RCTGetStorageDirectory() + withIntermediateDirectories:YES + attributes:nil + error:&error]; + if (error) { + return RCTMakeError(@"Failed to create storage directory.", error, nil); + } + + if (!_haveSetup) { + NSDictionary *errorOut = nil; + NSString *serialized = RCTReadFile(RCTGetManifestFilePath(), RCTManifestFileName, &errorOut); + if (!serialized) { + if (errorOut) { + RCTLogError(@"Could not open the existing manifest: %@", errorOut); + return errorOut; + } else { + _manifest = [NSMutableDictionary new]; + } + } else { + _manifest = RCTJSONParseMutable(serialized, &error); + if (!_manifest) { + RCTLogError(@"Failed to parse manifest - creating a new one: %@", error); + _manifest = [NSMutableDictionary new]; + } + } + _haveSetup = YES; + } + + return nil; +} + +- (NSDictionary *)_writeManifest:(NSMutableArray *__autoreleasing *)errors +{ + NSError *error; + NSString *serialized = RCTJSONStringify(_manifest, &error); + [serialized writeToFile:RCTGetManifestFilePath() + atomically:YES + encoding:NSUTF8StringEncoding + error:&error]; + NSDictionary *errorOut; + if (error) { + errorOut = RCTMakeError(@"Failed to write manifest file.", error, nil); + RCTAppendError(errorOut, errors); + } + return errorOut; +} + +- (NSString *)_getValueForKey:(NSString *)key errorOut:(NSDictionary *__autoreleasing *)errorOut +{ + NSString *value = _manifest[key]; + if (value == (id)kCFNull) { + value = [RCTGetCache() objectForKey:key]; + if (!value) { + NSString *filePath = [self _filePathForKey:key]; + value = RCTReadFile(filePath, key, errorOut); + if (value) { + [RCTGetCache() setObject:value forKey:key cost:value.length]; + } else { + [_manifest removeObjectForKey:key]; + } + } + } + return value; +} + +- (NSDictionary *)_writeEntry:(NSArray *)entry changedManifest:(BOOL *)changedManifest +{ + if (entry.count != 2) { + return RCTMakeAndLogError( + @"Entries must be arrays of the form [key: string, value: string], got: ", entry, nil); + } + NSString *key = entry[0]; + NSDictionary *errorOut = RCTErrorForKey(key); + if (errorOut) { + return errorOut; + } + NSString *value = entry[1]; + NSString *filePath = [self _filePathForKey:key]; + NSError *error; + if (value.length <= RCTInlineValueThreshold) { + if (_manifest[key] == (id)kCFNull) { + [[NSFileManager defaultManager] removeItemAtPath:filePath error:nil]; + [RCTGetCache() removeObjectForKey:key]; + } + *changedManifest = YES; + _manifest[key] = value; + return nil; + } + [value writeToFile:filePath atomically:YES encoding:NSUTF8StringEncoding error:&error]; + [RCTGetCache() setObject:value forKey:key cost:value.length]; + if (error) { + errorOut = RCTMakeError(@"Failed to write value.", error, @{@"key": key}); + } else if (_manifest[key] != (id)kCFNull) { + *changedManifest = YES; + _manifest[key] = (id)kCFNull; + } + return errorOut; +} + +- (void)_multiGet:(NSArray *)keys + callback:(RCTResponseSenderBlock)callback + getter:(NSString * (^)(NSUInteger i, NSString *key, NSDictionary **errorOut))getValue +{ + NSMutableArray *errors; + NSMutableArray *> *result = [NSMutableArray arrayWithCapacity:keys.count]; + for (NSUInteger i = 0; i < keys.count; ++i) { + NSString *key = keys[i]; + id keyError; + id value = getValue(i, key, &keyError); + [result addObject:@[key, RCTNullIfNil(value)]]; + RCTAppendError(keyError, &errors); + } + callback(@[RCTNullIfNil(errors), result]); +} + +- (BOOL)_passthroughDelegate +{ + return + [self.delegate respondsToSelector:@selector(isPassthrough)] && self.delegate.isPassthrough; +} + +#pragma mark - Exported JS Functions + +// clang-format off +RCT_EXPORT_METHOD(multiGet:(NSArray *)keys + callback:(RCTResponseSenderBlock)callback) +// clang-format on +{ + if (self.delegate != nil) { + [self.delegate + valuesForKeys:keys + completion:^(NSArray> *valuesOrErrors) { + [self _multiGet:keys + callback:callback + getter:^NSString *(NSUInteger i, NSString *key, NSDictionary **errorOut) { + id valueOrError = valuesOrErrors[i]; + if ([valueOrError isKindOfClass:[NSError class]]) { + NSError *error = (NSError *)valueOrError; + NSDictionary *extraData = @{@"key": RCTNullIfNil(key)}; + *errorOut = + RCTMakeError(error.localizedDescription, error, extraData); + return nil; + } else { + return [valueOrError isKindOfClass:[NSString class]] + ? (NSString *)valueOrError + : nil; + } + }]; + }]; + + if (![self _passthroughDelegate]) { + return; + } + } + + NSDictionary *ensureSetupErrorOut = [self _ensureSetup]; + if (ensureSetupErrorOut) { + callback(@[@[ensureSetupErrorOut], (id)kCFNull]); + return; + } + [self _multiGet:keys + callback:callback + getter:^(__unused NSUInteger i, NSString *key, NSDictionary **errorOut) { + return [self _getValueForKey:key errorOut:errorOut]; + }]; +} + +// clang-format off +RCT_EXPORT_METHOD(multiSet:(NSArray *> *)kvPairs + callback:(RCTResponseSenderBlock)callback) +// clang-format on +{ + if (self.delegate != nil) { + NSMutableArray *keys = [NSMutableArray arrayWithCapacity:kvPairs.count]; + NSMutableArray *values = [NSMutableArray arrayWithCapacity:kvPairs.count]; + for (NSArray *entry in kvPairs) { + [keys addObject:entry[0]]; + [values addObject:entry[1]]; + } + [self.delegate setValues:values + forKeys:keys + completion:^(NSArray> *results) { + NSArray *errors = RCTMakeErrors(results); + callback(@[RCTNullIfNil(errors)]); + }]; + + if (![self _passthroughDelegate]) { + return; + } + } + + NSDictionary *errorOut = [self _ensureSetup]; + if (errorOut) { + callback(@[@[errorOut]]); + return; + } + BOOL changedManifest = NO; + NSMutableArray *errors; + for (NSArray *entry in kvPairs) { + NSDictionary *keyError = [self _writeEntry:entry changedManifest:&changedManifest]; + RCTAppendError(keyError, &errors); + } + if (changedManifest) { + [self _writeManifest:&errors]; + } + callback(@[RCTNullIfNil(errors)]); +} + +// clang-format off +RCT_EXPORT_METHOD(multiMerge:(NSArray *> *)kvPairs + callback:(RCTResponseSenderBlock)callback) +// clang-format on +{ + if (self.delegate != nil) { + NSMutableArray *keys = [NSMutableArray arrayWithCapacity:kvPairs.count]; + NSMutableArray *values = [NSMutableArray arrayWithCapacity:kvPairs.count]; + for (NSArray *entry in kvPairs) { + [keys addObject:entry[0]]; + [values addObject:entry[1]]; + } + [self.delegate mergeValues:values + forKeys:keys + completion:^(NSArray> *results) { + NSArray *errors = RCTMakeErrors(results); + callback(@[RCTNullIfNil(errors)]); + }]; + + if (![self _passthroughDelegate]) { + return; + } + } + + NSDictionary *errorOut = [self _ensureSetup]; + if (errorOut) { + callback(@[@[errorOut]]); + return; + } + BOOL changedManifest = NO; + NSMutableArray *errors; + for (__strong NSArray *entry in kvPairs) { + NSDictionary *keyError; + NSString *value = [self _getValueForKey:entry[0] errorOut:&keyError]; + if (!keyError) { + if (value) { + NSError *jsonError; + NSMutableDictionary *mergedVal = RCTJSONParseMutable(value, &jsonError); + NSDictionary *mergingValue = RCTJSONParse(entry[1], &jsonError); + if (!mergingValue.count || RCTMergeRecursive(mergedVal, mergingValue)) { + entry = @[entry[0], RCTNullIfNil(RCTJSONStringify(mergedVal, NULL))]; + } + if (jsonError) { + keyError = RCTJSErrorFromNSError(jsonError); + } + } + if (!keyError) { + keyError = [self _writeEntry:entry changedManifest:&changedManifest]; + } + } + RCTAppendError(keyError, &errors); + } + if (changedManifest) { + [self _writeManifest:&errors]; + } + callback(@[RCTNullIfNil(errors)]); +} + +// clang-format off +RCT_EXPORT_METHOD(multiRemove:(NSArray *)keys + callback:(RCTResponseSenderBlock)callback) +// clang-format on +{ + if (self.delegate != nil) { + [self.delegate removeValuesForKeys:keys + completion:^(NSArray> *results) { + NSArray *errors = RCTMakeErrors(results); + callback(@[RCTNullIfNil(errors)]); + }]; + + if (![self _passthroughDelegate]) { + return; + } + } + + NSDictionary *errorOut = [self _ensureSetup]; + if (errorOut) { + callback(@[@[errorOut]]); + return; + } + NSMutableArray *errors; + BOOL changedManifest = NO; + for (NSString *key in keys) { + NSDictionary *keyError = RCTErrorForKey(key); + if (!keyError) { + if (_manifest[key] == (id)kCFNull) { + NSString *filePath = [self _filePathForKey:key]; + [[NSFileManager defaultManager] removeItemAtPath:filePath error:nil]; + [RCTGetCache() removeObjectForKey:key]; + } + if (_manifest[key]) { + changedManifest = YES; + [_manifest removeObjectForKey:key]; + } + } + RCTAppendError(keyError, &errors); + } + if (changedManifest) { + [self _writeManifest:&errors]; + } + callback(@[RCTNullIfNil(errors)]); +} + +// clang-format off +RCT_EXPORT_METHOD(clear:(RCTResponseSenderBlock)callback) +// clang-format on +{ + if (self.delegate != nil) { + [self.delegate removeAllValues:^(NSError *error) { + NSDictionary *result = nil; + if (error != nil) { + result = RCTMakeError(error.localizedDescription, error, nil); + } + callback(@[RCTNullIfNil(result)]); + }]; + return; + } + + [_manifest removeAllObjects]; + [RCTGetCache() removeAllObjects]; + NSDictionary *error = RCTDeleteStorageDirectory(); + callback(@[RCTNullIfNil(error)]); +} + +// clang-format off +RCT_EXPORT_METHOD(getAllKeys:(RCTResponseSenderBlock)callback) +// clang-format on +{ + if (self.delegate != nil) { + [self.delegate allKeys:^(NSArray> *keys) { + callback(@[(id)kCFNull, keys]); + }]; + + if (![self _passthroughDelegate]) { + return; + } + } + + NSDictionary *errorOut = [self _ensureSetup]; + if (errorOut) { + callback(@[errorOut, (id)kCFNull]); + } else { + callback(@[(id)kCFNull, _manifest.allKeys]); + } +} + +#pragma mark - RCTSandboxAwareModule + +- (void)configureSandboxWithOrigin:(NSString *)origin + requestedName:(NSString *)requestedName + resolvedName:(NSString *)resolvedName +{ + NSString *appSupport = NSSearchPathForDirectoriesInDomains( + NSApplicationSupportDirectory, NSUserDomainMask, YES).firstObject; + NSString *bundleId = [[NSBundle mainBundle] bundleIdentifier] ?: @"com.unknown"; + NSString *scopedDir = [[[[appSupport stringByAppendingPathComponent:bundleId] + stringByAppendingPathComponent:@"Sandboxes"] + stringByAppendingPathComponent:origin] + stringByAppendingPathComponent:@"AsyncStorage"]; + + NSLog(@"[SandboxedRNCAsyncStorage] Configuring for origin '%@', storage dir: %@", origin, scopedDir); + self.storageDirectory = scopedDir; + _configured = YES; +} + +#if RCT_NEW_ARCH_ENABLED +- (std::shared_ptr)getTurboModule: + (const facebook::react::ObjCTurboModule::InitParams &)params +{ + return std::make_shared(params); +} +#endif + +@end diff --git a/apps/fs-experiment/ios/SandboxedRNFSManager.h b/apps/fs-experiment/ios/SandboxedRNFSManager.h new file mode 100644 index 0000000..8046a97 --- /dev/null +++ b/apps/fs-experiment/ios/SandboxedRNFSManager.h @@ -0,0 +1,25 @@ +/** + * Sandboxed RNFSManager implementation for react-native-sandbox. + * + * Wraps the original RNFSManager from react-native-fs, scoping all file + * operations to a per-origin sandbox directory. Exposed directory constants + * (DocumentDirectoryPath, CachesDirectoryPath, etc.) are overridden to point + * into the sandbox root. + */ + +#import + +#import +#import +#import +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface SandboxedRNFSManager : RCTEventEmitter + +@property (nonatomic, copy) NSString *sandboxRoot; + +@end + +NS_ASSUME_NONNULL_END diff --git a/apps/fs-experiment/ios/SandboxedRNFSManager.mm b/apps/fs-experiment/ios/SandboxedRNFSManager.mm new file mode 100644 index 0000000..a493146 --- /dev/null +++ b/apps/fs-experiment/ios/SandboxedRNFSManager.mm @@ -0,0 +1,551 @@ +/** + * Sandboxed RNFSManager — jails all file paths to a per-origin directory. + * + * Every path argument is validated against the sandbox root. Directory + * constants exposed to JS (RNFSDocumentDirectoryPath, etc.) are overridden. + */ + +#import "SandboxedRNFSManager.h" + +#import +#import +#import +#import + +@implementation SandboxedRNFSManager { + dispatch_queue_t _methodQueue; + NSString *_documentsDir; + NSString *_cachesDir; + NSString *_tempDir; + NSString *_libraryDir; + BOOL _configured; +} + +RCT_EXPORT_MODULE(SandboxedRNFSManager) + ++ (BOOL)requiresMainQueueSetup { return NO; } + +- (dispatch_queue_t)methodQueue +{ + if (!_methodQueue) { + _methodQueue = dispatch_queue_create("sandbox.rnfs", DISPATCH_QUEUE_SERIAL); + } + return _methodQueue; +} + +- (NSArray *)supportedEvents +{ + return @[@"DownloadBegin", @"DownloadProgress", @"DownloadResumable", + @"UploadBegin", @"UploadProgress"]; +} + +#pragma mark - Sandbox setup + +- (void)_setupDirectoriesForOrigin:(NSString *)origin +{ + NSString *appSupport = NSSearchPathForDirectoriesInDomains( + NSApplicationSupportDirectory, NSUserDomainMask, YES).firstObject; + NSString *bundleId = [[NSBundle mainBundle] bundleIdentifier] ?: @"com.unknown"; + _sandboxRoot = [[[appSupport stringByAppendingPathComponent:bundleId] + stringByAppendingPathComponent:@"Sandboxes"] + stringByAppendingPathComponent:origin]; + + _documentsDir = [_sandboxRoot stringByAppendingPathComponent:@"Documents"]; + _cachesDir = [_sandboxRoot stringByAppendingPathComponent:@"Caches"]; + _tempDir = [_sandboxRoot stringByAppendingPathComponent:@"tmp"]; + _libraryDir = [_sandboxRoot stringByAppendingPathComponent:@"Library"]; + + NSFileManager *fm = [NSFileManager defaultManager]; + for (NSString *dir in @[_documentsDir, _cachesDir, _tempDir, _libraryDir]) { + [fm createDirectoryAtPath:dir withIntermediateDirectories:YES attributes:nil error:nil]; + } + + _configured = YES; +} + +#pragma mark - RCTSandboxAwareModule + +- (void)configureSandboxWithOrigin:(NSString *)origin + requestedName:(NSString *)requestedName + resolvedName:(NSString *)resolvedName +{ + NSLog(@"[SandboxedRNFSManager] Configuring for origin '%@'", origin); + [self _setupDirectoriesForOrigin:origin]; +} + +#pragma mark - Path validation + +- (nullable NSString *)_sandboxedPath:(NSString *)path + reject:(RCTPromiseRejectBlock)reject +{ + if (!_configured) { + reject(@"EPERM", @"SandboxedRNFSManager: sandbox not configured. " + "configureSandboxWithOrigin: must be called before any file operation.", nil); + return nil; + } + + NSString *resolved; + if ([path hasPrefix:@"/"]) { + resolved = [path stringByStandardizingPath]; + } else { + resolved = [[_documentsDir stringByAppendingPathComponent:path] stringByStandardizingPath]; + } + + if ([resolved hasPrefix:_sandboxRoot]) { + return resolved; + } + + reject(@"EPERM", [NSString stringWithFormat: + @"Path '%@' is outside the sandbox. Allowed root: %@", path, _sandboxRoot], nil); + return nil; +} + +- (nullable NSString *)_sandboxedSrcPath:(NSString *)path + reject:(RCTPromiseRejectBlock)reject +{ + return [self _sandboxedPath:path reject:reject]; +} + +#pragma mark - Constants + +- (NSDictionary *)constantsToExport +{ + if (!_configured) { + return @{}; + } + return @{ + @"RNFSMainBundlePath": _documentsDir, // no access to real main bundle + @"RNFSCachesDirectoryPath": _cachesDir, + @"RNFSDocumentDirectoryPath": _documentsDir, + @"RNFSExternalDirectoryPath": [NSNull null], + @"RNFSExternalStorageDirectoryPath": [NSNull null], + @"RNFSExternalCachesDirectoryPath": [NSNull null], + @"RNFSDownloadDirectoryPath": [NSNull null], + @"RNFSTemporaryDirectoryPath": _tempDir, + @"RNFSLibraryDirectoryPath": _libraryDir, + @"RNFSPicturesDirectoryPath": [NSNull null], + @"RNFSFileTypeRegular": NSFileTypeRegular, + @"RNFSFileTypeDirectory": NSFileTypeDirectory, + @"RNFSFileProtectionComplete": NSFileProtectionComplete, + @"RNFSFileProtectionCompleteUnlessOpen": NSFileProtectionCompleteUnlessOpen, + @"RNFSFileProtectionCompleteUntilFirstUserAuthentication": NSFileProtectionCompleteUntilFirstUserAuthentication, + @"RNFSFileProtectionNone": NSFileProtectionNone, + }; +} + +#pragma mark - File operations + +RCT_EXPORT_METHOD(writeFile:(NSString *)filepath + contents:(NSString *)base64Content + options:(NSDictionary *)options + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) +{ + NSString *path = [self _sandboxedPath:filepath reject:reject]; + if (!path) return; + + NSData *data = [[NSData alloc] initWithBase64EncodedString:base64Content + options:NSDataBase64DecodingIgnoreUnknownCharacters]; + BOOL success = [[NSFileManager defaultManager] createFileAtPath:path contents:data attributes:nil]; + if (!success) { + reject(@"ENOENT", [NSString stringWithFormat:@"ENOENT: could not write '%@'", path], nil); + return; + } + resolve(nil); +} + +RCT_EXPORT_METHOD(readFile:(NSString *)filepath + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) +{ + NSString *path = [self _sandboxedPath:filepath reject:reject]; + if (!path) return; + + if (![[NSFileManager defaultManager] fileExistsAtPath:path]) { + reject(@"ENOENT", [NSString stringWithFormat:@"ENOENT: no such file '%@'", path], nil); + return; + } + NSData *content = [[NSFileManager defaultManager] contentsAtPath:path]; + resolve([content base64EncodedStringWithOptions:NSDataBase64EncodingEndLineWithLineFeed]); +} + +RCT_EXPORT_METHOD(readDir:(NSString *)dirPath + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) +{ + NSString *path = [self _sandboxedPath:dirPath reject:reject]; + if (!path) return; + + NSFileManager *fm = [NSFileManager defaultManager]; + NSError *error; + NSArray *contents = [fm contentsOfDirectoryAtPath:path error:&error]; + if (error) { + reject(@"ENOENT", error.localizedDescription, error); + return; + } + + NSMutableArray *result = [NSMutableArray new]; + for (NSString *name in contents) { + NSString *fullPath = [path stringByAppendingPathComponent:name]; + NSDictionary *attrs = [fm attributesOfItemAtPath:fullPath error:nil]; + if (attrs) { + [result addObject:@{ + @"ctime": @([(NSDate *)attrs[NSFileCreationDate] timeIntervalSince1970]), + @"mtime": @([(NSDate *)attrs[NSFileModificationDate] timeIntervalSince1970]), + @"name": name, + @"path": fullPath, + @"size": attrs[NSFileSize], + @"type": attrs[NSFileType], + }]; + } + } + resolve(result); +} + +RCT_EXPORT_METHOD(exists:(NSString *)filepath + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) +{ + NSString *path = [self _sandboxedPath:filepath reject:reject]; + if (!path) return; + resolve(@([[NSFileManager defaultManager] fileExistsAtPath:path])); +} + +RCT_EXPORT_METHOD(stat:(NSString *)filepath + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) +{ + NSString *path = [self _sandboxedPath:filepath reject:reject]; + if (!path) return; + + NSError *error; + NSDictionary *attrs = [[NSFileManager defaultManager] attributesOfItemAtPath:path error:&error]; + if (error) { + reject(@"ENOENT", error.localizedDescription, error); + return; + } + + resolve(@{ + @"ctime": @([(NSDate *)attrs[NSFileCreationDate] timeIntervalSince1970]), + @"mtime": @([(NSDate *)attrs[NSFileModificationDate] timeIntervalSince1970]), + @"size": attrs[NSFileSize], + @"type": attrs[NSFileType], + @"mode": @([[(NSNumber *)attrs[NSFilePosixPermissions] stringValue] integerValue]), + }); +} + +RCT_EXPORT_METHOD(unlink:(NSString *)filepath + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) +{ + NSString *path = [self _sandboxedPath:filepath reject:reject]; + if (!path) return; + + NSError *error; + if (![[NSFileManager defaultManager] removeItemAtPath:path error:&error]) { + reject(@"ENOENT", error.localizedDescription, error); + return; + } + resolve(nil); +} + +RCT_EXPORT_METHOD(mkdir:(NSString *)filepath + options:(NSDictionary *)options + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) +{ + NSString *path = [self _sandboxedPath:filepath reject:reject]; + if (!path) return; + + NSError *error; + if (![[NSFileManager defaultManager] createDirectoryAtPath:path + withIntermediateDirectories:YES + attributes:nil + error:&error]) { + reject(@"ENOENT", error.localizedDescription, error); + return; + } + resolve(nil); +} + +RCT_EXPORT_METHOD(moveFile:(NSString *)filepath + destPath:(NSString *)destPath + options:(NSDictionary *)options + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) +{ + NSString *src = [self _sandboxedPath:filepath reject:reject]; + if (!src) return; + NSString *dst = [self _sandboxedPath:destPath reject:reject]; + if (!dst) return; + + NSError *error; + if (![[NSFileManager defaultManager] moveItemAtPath:src toPath:dst error:&error]) { + reject(@"ENOENT", error.localizedDescription, error); + return; + } + resolve(nil); +} + +RCT_EXPORT_METHOD(copyFile:(NSString *)filepath + destPath:(NSString *)destPath + options:(NSDictionary *)options + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) +{ + NSString *src = [self _sandboxedPath:filepath reject:reject]; + if (!src) return; + NSString *dst = [self _sandboxedPath:destPath reject:reject]; + if (!dst) return; + + NSError *error; + if (![[NSFileManager defaultManager] copyItemAtPath:src toPath:dst error:&error]) { + reject(@"ENOENT", error.localizedDescription, error); + return; + } + resolve(nil); +} + +RCT_EXPORT_METHOD(appendFile:(NSString *)filepath + contents:(NSString *)base64Content + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) +{ + NSString *path = [self _sandboxedPath:filepath reject:reject]; + if (!path) return; + + NSData *data = [[NSData alloc] initWithBase64EncodedString:base64Content + options:NSDataBase64DecodingIgnoreUnknownCharacters]; + NSFileManager *fm = [NSFileManager defaultManager]; + if (![fm fileExistsAtPath:path]) { + if (![fm createFileAtPath:path contents:data attributes:nil]) { + reject(@"ENOENT", [NSString stringWithFormat:@"ENOENT: could not create '%@'", path], nil); + return; + } + resolve(nil); + return; + } + + @try { + NSFileHandle *fh = [NSFileHandle fileHandleForUpdatingAtPath:path]; + [fh seekToEndOfFile]; + [fh writeData:data]; + resolve(nil); + } @catch (NSException *e) { + reject(@"ENOENT", e.reason, nil); + } +} + +RCT_EXPORT_METHOD(write:(NSString *)filepath + contents:(NSString *)base64Content + position:(NSInteger)position + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) +{ + NSString *path = [self _sandboxedPath:filepath reject:reject]; + if (!path) return; + + NSData *data = [[NSData alloc] initWithBase64EncodedString:base64Content + options:NSDataBase64DecodingIgnoreUnknownCharacters]; + NSFileManager *fm = [NSFileManager defaultManager]; + if (![fm fileExistsAtPath:path]) { + if (![fm createFileAtPath:path contents:data attributes:nil]) { + reject(@"ENOENT", [NSString stringWithFormat:@"ENOENT: could not create '%@'", path], nil); + return; + } + resolve(nil); + return; + } + + @try { + NSFileHandle *fh = [NSFileHandle fileHandleForUpdatingAtPath:path]; + if (position >= 0) { + [fh seekToFileOffset:position]; + } else { + [fh seekToEndOfFile]; + } + [fh writeData:data]; + resolve(nil); + } @catch (NSException *e) { + reject(@"ENOENT", e.reason, nil); + } +} + +RCT_EXPORT_METHOD(read:(NSString *)filepath + length:(NSInteger)length + position:(NSInteger)position + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) +{ + NSString *path = [self _sandboxedPath:filepath reject:reject]; + if (!path) return; + + if (![[NSFileManager defaultManager] fileExistsAtPath:path]) { + reject(@"ENOENT", [NSString stringWithFormat:@"ENOENT: no such file '%@'", path], nil); + return; + } + + NSFileHandle *fh = [NSFileHandle fileHandleForReadingAtPath:path]; + if (!fh) { + reject(@"ENOENT", @"Could not open file for reading", nil); + return; + } + [fh seekToFileOffset:(unsigned long long)position]; + NSData *content = (length > 0) ? [fh readDataOfLength:length] : [fh readDataToEndOfFile]; + resolve([content base64EncodedStringWithOptions:NSDataBase64EncodingEndLineWithLineFeed]); +} + +RCT_EXPORT_METHOD(hash:(NSString *)filepath + algorithm:(NSString *)algorithm + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) +{ + NSString *path = [self _sandboxedPath:filepath reject:reject]; + if (!path) return; + + if (![[NSFileManager defaultManager] fileExistsAtPath:path]) { + reject(@"ENOENT", [NSString stringWithFormat:@"ENOENT: no such file '%@'", path], nil); + return; + } + + NSData *content = [[NSFileManager defaultManager] contentsAtPath:path]; + + NSDictionary *digestLengths = @{ + @"md5": @(CC_MD5_DIGEST_LENGTH), + @"sha1": @(CC_SHA1_DIGEST_LENGTH), + @"sha224": @(CC_SHA224_DIGEST_LENGTH), + @"sha256": @(CC_SHA256_DIGEST_LENGTH), + @"sha384": @(CC_SHA384_DIGEST_LENGTH), + @"sha512": @(CC_SHA512_DIGEST_LENGTH), + }; + + int digestLength = [digestLengths[algorithm] intValue]; + if (!digestLength) { + reject(@"Error", [NSString stringWithFormat:@"Invalid hash algorithm '%@'", algorithm], nil); + return; + } + + unsigned char buffer[CC_SHA512_DIGEST_LENGTH]; + if ([algorithm isEqualToString:@"md5"]) CC_MD5(content.bytes, (CC_LONG)content.length, buffer); + else if ([algorithm isEqualToString:@"sha1"]) CC_SHA1(content.bytes, (CC_LONG)content.length, buffer); + else if ([algorithm isEqualToString:@"sha224"]) CC_SHA224(content.bytes, (CC_LONG)content.length, buffer); + else if ([algorithm isEqualToString:@"sha256"]) CC_SHA256(content.bytes, (CC_LONG)content.length, buffer); + else if ([algorithm isEqualToString:@"sha384"]) CC_SHA384(content.bytes, (CC_LONG)content.length, buffer); + else if ([algorithm isEqualToString:@"sha512"]) CC_SHA512(content.bytes, (CC_LONG)content.length, buffer); + + NSMutableString *output = [NSMutableString stringWithCapacity:digestLength * 2]; + for (int i = 0; i < digestLength; i++) { + [output appendFormat:@"%02x", buffer[i]]; + } + resolve(output); +} + +RCT_EXPORT_METHOD(getFSInfo:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) +{ + NSError *error; + NSDictionary *attrs = [[NSFileManager defaultManager] + attributesOfFileSystemForPath:_sandboxRoot error:&error]; + if (error) { + reject(@"Error", error.localizedDescription, error); + return; + } + resolve(@{ + @"totalSpace": attrs[NSFileSystemSize], + @"freeSpace": attrs[NSFileSystemFreeSize], + }); +} + +RCT_EXPORT_METHOD(touch:(NSString *)filepath + mtime:(NSDate *)mtime + ctime:(NSDate *)ctime + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) +{ + NSString *path = [self _sandboxedPath:filepath reject:reject]; + if (!path) return; + + NSMutableDictionary *attr = [NSMutableDictionary new]; + if (mtime) attr[NSFileModificationDate] = mtime; + if (ctime) attr[NSFileCreationDate] = ctime; + + NSError *error; + if (![[NSFileManager defaultManager] setAttributes:attr ofItemAtPath:path error:&error]) { + reject(@"ENOENT", error.localizedDescription, error); + return; + } + resolve(nil); +} + +#pragma mark - Stubbed network operations (blocked in sandbox) + +RCT_EXPORT_METHOD(downloadFile:(NSDictionary *)options + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) +{ + reject(@"EPERM", @"downloadFile is not available in sandboxed mode", nil); +} + +RCT_EXPORT_METHOD(stopDownload:(nonnull NSNumber *)jobId) +{ + RCTLogWarn(@"[SandboxedRNFSManager] stopDownload blocked in sandbox"); +} + +RCT_EXPORT_METHOD(resumeDownload:(nonnull NSNumber *)jobId) +{ + RCTLogWarn(@"[SandboxedRNFSManager] resumeDownload blocked in sandbox"); +} + +RCT_EXPORT_METHOD(isResumable:(nonnull NSNumber *)jobId + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) +{ + resolve(@NO); +} + +RCT_EXPORT_METHOD(uploadFiles:(NSDictionary *)options + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) +{ + reject(@"EPERM", @"uploadFiles is not available in sandboxed mode", nil); +} + +RCT_EXPORT_METHOD(stopUpload:(nonnull NSNumber *)jobId) +{ + RCTLogWarn(@"[SandboxedRNFSManager] stopUpload blocked in sandbox"); +} + +RCT_EXPORT_METHOD(completeHandlerIOS:(nonnull NSNumber *)jobId + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) +{ + resolve(nil); +} + +RCT_EXPORT_METHOD(pathForBundle:(NSString *)bundleNamed + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) +{ + reject(@"EPERM", @"pathForBundle is not available in sandboxed mode", nil); +} + +RCT_EXPORT_METHOD(pathForGroup:(NSString *)groupId + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) +{ + reject(@"EPERM", @"pathForGroup is not available in sandboxed mode", nil); +} + +// addListener / removeListeners required by RCTEventEmitter +RCT_EXPORT_METHOD(addListener:(NSString *)eventName) {} +RCT_EXPORT_METHOD(removeListeners:(double)count) {} + +#pragma mark - RCTTurboModule + +- (std::shared_ptr)getTurboModule: + (const facebook::react::ObjCTurboModule::InitParams &)params +{ + return std::make_shared(params); +} + +@end diff --git a/apps/fs-experiment/package.json b/apps/fs-experiment/package.json index 384693a..fab9a94 100644 --- a/apps/fs-experiment/package.json +++ b/apps/fs-experiment/package.json @@ -6,9 +6,7 @@ "android": "react-native run-android", "ios": "react-native run-ios", "start": "react-native start", - "bundle:sandbox": "bun run bundle:sandbox-fs && bun run bundle:sandbox-file-access", - "bundle:sandbox-fs": "npx react-native bundle --platform ios --dev false --entry-file sandbox-fs.js --bundle-output ios/sandbox-fs.jsbundle --assets-dest ios/", - "bundle:sandbox-file-access": "npx react-native bundle --platform ios --dev false --entry-file sandbox-file-access.js --bundle-output ios/sandbox-file-access.jsbundle --assets-dest ios/", + "bundle:sandbox": "npx react-native bundle --platform ios --dev false --entry-file sandbox.js --bundle-output ios/sandbox.jsbundle --assets-dest ios/", "typecheck": "tsc --noEmit", "jest": "echo 'No tests'" }, @@ -16,6 +14,7 @@ "react": "19.1.0", "react-native": "0.80.1", "@callstack/react-native-sandbox": "workspace:*", + "@react-native-async-storage/async-storage": "^2.1.2", "react-native-fs": "^2.20.0", "react-native-file-access": "^3.1.1" }, diff --git a/apps/fs-experiment/sandbox-file-access.js b/apps/fs-experiment/sandbox-file-access.js deleted file mode 100644 index 1fca809..0000000 --- a/apps/fs-experiment/sandbox-file-access.js +++ /dev/null @@ -1,5 +0,0 @@ -import {AppRegistry} from 'react-native' - -import SandboxFileAccess from './SandboxFileAccess' - -AppRegistry.registerComponent('AppFileAccess', () => SandboxFileAccess) diff --git a/apps/fs-experiment/sandbox-fs.js b/apps/fs-experiment/sandbox-fs.js deleted file mode 100644 index 12eec66..0000000 --- a/apps/fs-experiment/sandbox-fs.js +++ /dev/null @@ -1,5 +0,0 @@ -import {AppRegistry} from 'react-native' - -import SandboxFS from './SandboxFS' - -AppRegistry.registerComponent('AppFS', () => SandboxFS) diff --git a/apps/fs-experiment/sandbox.js b/apps/fs-experiment/sandbox.js new file mode 100644 index 0000000..5d6bb87 --- /dev/null +++ b/apps/fs-experiment/sandbox.js @@ -0,0 +1,5 @@ +import {AppRegistry} from 'react-native' + +import FileOpsUI from './FileOpsUI' + +AppRegistry.registerComponent('SandboxApp', () => FileOpsUI) diff --git a/bun.lock b/bun.lock index 2ea6ff6..d346566 100644 --- a/bun.lock +++ b/bun.lock @@ -67,6 +67,7 @@ "version": "1.0.0", "dependencies": { "@callstack/react-native-sandbox": "workspace:*", + "@react-native-async-storage/async-storage": "^2.1.2", "react": "19.1.0", "react-native": "0.80.1", "react-native-file-access": "^3.1.1", @@ -178,7 +179,7 @@ }, "packages/react-native-sandbox": { "name": "@callstack/react-native-sandbox", - "version": "0.4.0", + "version": "0.4.1", "devDependencies": { "react": "19.1.0", "react-native": "0.80.1", @@ -680,6 +681,8 @@ "@pnpm/npm-conf": ["@pnpm/npm-conf@2.3.1", "", { "dependencies": { "@pnpm/config.env-replace": "^1.1.0", "@pnpm/network.ca-file": "^1.0.1", "config-chain": "^1.1.11" } }, "sha512-c83qWb22rNRuB0UaVCI0uRPNRr8Z0FWnEIvT47jiHAmOIUHbBOg5XvV7pM5x+rKn9HRpjxquDbXYSXr3fAKFcw=="], + "@react-native-async-storage/async-storage": ["@react-native-async-storage/async-storage@2.2.0", "", { "dependencies": { "merge-options": "^3.0.4" }, "peerDependencies": { "react-native": "^0.0.0-0 || >=0.65 <1.0" } }, "sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw=="], + "@react-native-community/cli": ["@react-native-community/cli@18.0.0", "", { "dependencies": { "@react-native-community/cli-clean": "18.0.0", "@react-native-community/cli-config": "18.0.0", "@react-native-community/cli-doctor": "18.0.0", "@react-native-community/cli-server-api": "18.0.0", "@react-native-community/cli-tools": "18.0.0", "@react-native-community/cli-types": "18.0.0", "chalk": "^4.1.2", "commander": "^9.4.1", "deepmerge": "^4.3.0", "execa": "^5.0.0", "find-up": "^5.0.0", "fs-extra": "^8.1.0", "graceful-fs": "^4.1.3", "prompts": "^2.4.2", "semver": "^7.5.2" }, "bin": { "rnc-cli": "build/bin.js" } }, "sha512-DyKptlG78XPFo7tDod+we5a3R+U9qjyhaVFbOPvH4pFNu5Dehewtol/srl44K6Cszq0aEMlAJZ3juk0W4WnOJA=="], "@react-native-community/cli-clean": ["@react-native-community/cli-clean@18.0.0", "", { "dependencies": { "@react-native-community/cli-tools": "18.0.0", "chalk": "^4.1.2", "execa": "^5.0.0", "fast-glob": "^3.3.2" } }, "sha512-+k64EnJaMI5U8iNDF9AftHBJW+pO/isAhncEXuKRc6IjRtIh6yoaUIIf5+C98fgjfux7CNRZAMQIkPbZodv2Gw=="], @@ -1710,7 +1713,7 @@ "is-path-inside": ["is-path-inside@3.0.3", "", {}, "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ=="], - "is-plain-obj": ["is-plain-obj@1.1.0", "", {}, "sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg=="], + "is-plain-obj": ["is-plain-obj@2.1.0", "", {}, "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA=="], "is-plain-object": ["is-plain-object@5.0.0", "", {}, "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q=="], @@ -1962,6 +1965,8 @@ "meow": ["meow@12.1.1", "", {}, "sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw=="], + "merge-options": ["merge-options@3.0.4", "", { "dependencies": { "is-plain-obj": "^2.1.0" } }, "sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ=="], + "merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="], "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], @@ -3094,6 +3099,8 @@ "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + "minimist-options/is-plain-obj": ["is-plain-obj@1.1.0", "", {}, "sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg=="], + "normalize-package-data/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], "npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], diff --git a/packages/react-native-sandbox/README.md b/packages/react-native-sandbox/README.md index 385ec67..02e66a0 100644 --- a/packages/react-native-sandbox/README.md +++ b/packages/react-native-sandbox/README.md @@ -53,6 +53,7 @@ import SandboxReactNativeView from '@callstack/react-native-sandbox'; | `initialProperties` | `object` | :white_large_square: | `{}` | Initial props for the sandboxed app | | `launchOptions` | `object` | :white_large_square: | `{}` | Launch configuration options | | `allowedTurboModules` | `string[]` | :white_large_square: | [check here](https://github.com/callstackincubator/react-native-sandbox/blob/main/packages/react-native-sandbox/src/index.tsx#L18) | Additional TurboModules to allow | +| `turboModuleSubstitutions` | `Record` | :white_large_square: | `undefined` | Map of module name substitutions (requested → resolved). Substituted modules are implicitly allowed. | | `onMessage` | `function` | :white_large_square: | `undefined` | Callback for messages from sandbox | | `onError` | `function` | :white_large_square: | `undefined` | Callback for sandbox errors | | `style` | `ViewStyle` | :white_large_square: | `undefined` | Container styling | @@ -99,6 +100,27 @@ Use `allowedTurboModules` to control which native modules the sandbox can access > Note: This filtering works with both legacy native modules and new TurboModules, ensuring compatibility across React Native versions. +#### TurboModule Substitutions + +Use `turboModuleSubstitutions` to transparently replace a module with a sandbox-aware implementation. When sandbox JS requests a module by name, the substitution map redirects it to a different native module: + +```tsx + +``` + +Substituted modules are **implicitly allowed** and don't need to be listed in `allowedTurboModules`. If the resolved module conforms to `RCTSandboxAwareModule` (ObjC) or `ISandboxAwareModule` (C++), it receives sandbox context (origin, requested name, resolved name) after instantiation — enabling per-origin data scoping. + +Changing `turboModuleSubstitutions` at runtime triggers a full re-instantiation of the sandbox's React Native runtime, ensuring TurboModules are re-resolved with the new configuration. + +See the [`apps/fs-experiment`](https://github.com/callstackincubator/react-native-sandbox/tree/main/apps/fs-experiment) example for a working demonstration. + #### Message Origin Control Use `allowedOrigins` to specify which sandbox origins are allowed to send messages to this sandbox: diff --git a/packages/react-native-sandbox/ios/ISandboxAwareModule.h b/packages/react-native-sandbox/ios/ISandboxAwareModule.h new file mode 100644 index 0000000..2a3078c --- /dev/null +++ b/packages/react-native-sandbox/ios/ISandboxAwareModule.h @@ -0,0 +1,66 @@ +#pragma once + +#ifdef __cplusplus + +#include + +namespace facebook { +namespace react { + +/** + * Context information provided to sandbox-aware TurboModules. + * Contains the sandbox identity and module mapping details needed + * for scoping module behavior per sandbox instance. + */ +struct SandboxContext { + /** The origin identifier of the sandbox instance */ + std::string origin; + + /** The module name that sandbox JS code requested (e.g. "RNCAsyncStorage") */ + std::string requestedModuleName; + + /** The actual module name that was resolved via substitution (e.g. + * "SandboxedAsyncStorage") */ + std::string resolvedModuleName; +}; + +/** + * Interface for TurboModules that need sandbox-specific configuration. + * + * When a TurboModule is provided as a substitution in the sandbox, + * the sandbox delegate will check if the module implements this interface + * and call configureSandbox() with the relevant context. + * + * This enables modules to scope their behavior per sandbox origin, + * e.g. sandboxing file system access to a per-origin directory or + * isolating AsyncStorage keys by origin. + * + * Usage: + * @code + * class SandboxedAsyncStorage : public TurboModule, public ISandboxAwareModule + * { public: void configureSandbox(const SandboxContext& context) override { + * // Scope storage to this sandbox's origin + * storagePrefix_ = context.origin; + * } + * }; + * @endcode + */ +class ISandboxAwareModule { + public: + virtual ~ISandboxAwareModule() = default; + + /** + * Called by the sandbox delegate after module instantiation to provide + * sandbox-specific context. Implementations should use this to scope + * their behavior (storage, file paths, etc.) to the given sandbox. + * + * @param context The sandbox context containing origin and module mapping + * info + */ + virtual void configureSandbox(const SandboxContext& context) = 0; +}; + +} // namespace react +} // namespace facebook + +#endif // __cplusplus diff --git a/packages/react-native-sandbox/ios/RCTSandboxAwareModule.h b/packages/react-native-sandbox/ios/RCTSandboxAwareModule.h new file mode 100644 index 0000000..411fc73 --- /dev/null +++ b/packages/react-native-sandbox/ios/RCTSandboxAwareModule.h @@ -0,0 +1,41 @@ +#import + +NS_ASSUME_NONNULL_BEGIN + +/** + * ObjC protocol equivalent of ISandboxAwareModule for ObjC TurboModules. + * + * When a TurboModule substitution resolves an ObjC module, the sandbox delegate + * checks if the module conforms to this protocol and calls configureSandbox: + * with context about the sandbox instance. + * + * @code + * @interface SandboxedAsyncStorage : NSObject + * @end + * + * @implementation SandboxedAsyncStorage + * - (void)configureSandboxWithOrigin:(NSString *)origin + * requestedName:(NSString *)requestedName + * resolvedName:(NSString *)resolvedName { + * self.storageDirectory = [basePath stringByAppendingPathComponent:origin]; + * } + * @end + * @endcode + */ +@protocol RCTSandboxAwareModule + +/** + * Called by the sandbox delegate after module instantiation to provide + * sandbox-specific context. + * + * @param origin The sandbox origin identifier + * @param requestedName The module name sandbox JS code requested + * @param resolvedName The actual module name that was resolved + */ +- (void)configureSandboxWithOrigin:(NSString *)origin + requestedName:(NSString *)requestedName + resolvedName:(NSString *)resolvedName; + +@end + +NS_ASSUME_NONNULL_END diff --git a/packages/react-native-sandbox/ios/SandboxReactNativeDelegate.h b/packages/react-native-sandbox/ios/SandboxReactNativeDelegate.h index 1ec162d..d23f780 100644 --- a/packages/react-native-sandbox/ios/SandboxReactNativeDelegate.h +++ b/packages/react-native-sandbox/ios/SandboxReactNativeDelegate.h @@ -11,6 +11,7 @@ #import #import +#include #include #include @@ -44,6 +45,18 @@ NS_ASSUME_NONNULL_BEGIN */ @property (nonatomic, readwrite) std::set allowedOrigins; +/** + * Sets the TurboModule substitution map for this sandbox instance. + * Keys are module names that sandbox JS code requests, values are the actual + * native module names to resolve instead. Substituted modules are implicitly allowed. + * + * Example: {"RNCAsyncStorage": "SandboxedAsyncStorage"} means when sandbox JS + * requests RNCAsyncStorage, the delegate resolves SandboxedAsyncStorage instead + * and configures it with the sandbox context (origin, etc.) if it implements + * ISandboxAwareModule. + */ +@property (nonatomic, readwrite) std::map turboModuleSubstitutions; + /** * Initializes the delegate. * @return Initialized delegate instance with filtered module access diff --git a/packages/react-native-sandbox/ios/SandboxReactNativeDelegate.mm b/packages/react-native-sandbox/ios/SandboxReactNativeDelegate.mm index 6a628d4..0d2319f 100644 --- a/packages/react-native-sandbox/ios/SandboxReactNativeDelegate.mm +++ b/packages/react-native-sandbox/ios/SandboxReactNativeDelegate.mm @@ -14,15 +14,19 @@ #include #include +#import #import #import #import #import +#import #import #import #include +#include "ISandboxAwareModule.h" +#import "RCTSandboxAwareModule.h" #include "SandboxDelegateWrapper.h" #include "SandboxRegistry.h" #import "StubTurboModuleCxx.h" @@ -31,6 +35,30 @@ namespace TurboModuleConvertUtils = facebook::react::TurboModuleConvertUtils; using namespace facebook::react; +class SandboxNativeMethodCallInvoker : public NativeMethodCallInvoker { + dispatch_queue_t methodQueue_; + + public: + explicit SandboxNativeMethodCallInvoker(dispatch_queue_t methodQueue) : methodQueue_(methodQueue) {} + + void invokeAsync(const std::string &, std::function &&work) noexcept override + { + if (methodQueue_ == RCTJSThread) { + work(); + return; + } + __block auto retainedWork = std::move(work); + dispatch_async(methodQueue_, ^{ + retainedWork(); + }); + } + + void invokeSync(const std::string &, std::function &&work) override + { + work(); + } +}; + static void stubJsiFunction(jsi::Runtime &runtime, jsi::Object &object, const char *name) { object.setProperty( @@ -56,8 +84,10 @@ @interface SandboxReactNativeDelegate () { std::shared_ptr _onMessageSandbox; std::set _allowedTurboModules; std::set _allowedOrigins; + std::map _turboModuleSubstitutions; std::string _origin; std::string _jsBundleSource; + NSMutableDictionary> *_substitutedModuleInstances; } - (void)cleanupResources; @@ -80,6 +110,7 @@ - (instancetype)init if (self = [super init]) { _hasOnMessageHandler = NO; _hasOnErrorHandler = NO; + _substitutedModuleInstances = [NSMutableDictionary new]; self.dependencyProvider = [[RCTAppDependencyProvider alloc] init]; } return self; @@ -91,6 +122,8 @@ - (void)cleanupResources _rctInstance = nil; _allowedTurboModules.clear(); _allowedOrigins.clear(); + _turboModuleSubstitutions.clear(); + [_substitutedModuleInstances removeAllObjects]; } #pragma mark - C++ Property Getters @@ -160,6 +193,16 @@ - (void)setAllowedTurboModules:(std::set)allowedTurboModules _allowedTurboModules = allowedTurboModules; } +- (std::map)turboModuleSubstitutions +{ + return _turboModuleSubstitutions; +} + +- (void)setTurboModuleSubstitutions:(std::map)turboModuleSubstitutions +{ + _turboModuleSubstitutions = turboModuleSubstitutions; +} + - (void)dealloc { if (!_origin.empty()) { @@ -288,22 +331,251 @@ - (void)hostDidStart:(RCTHost *)host }]; } -#pragma mark - RCTTurboModuleManagerDelegate +/** + * RCTTurboModuleManagerDelegate resolution order (called by RCTTurboModuleManager): + * + * PRIORITY 1 — getTurboModule:jsInvoker: + * Called first. Returns a fully constructed C++ TurboModule (shared_ptr). + * If non-null, resolution stops here — nothing else is called. + * This is the primary path for C++ TurboModules and our ObjC substitution fallback. + * + * PRIORITY 2 — getModuleClassFromName: + * Called if getTurboModule returned nullptr. Provides the ObjC class for a module name. + * The TurboModuleManager then calls getModuleInstanceFromClass: with this class. + * + * PRIORITY 3 — getModuleInstanceFromClass: + * Called with the class from step 2 (or the auto-registered class). + * Creates and returns an ObjC module instance. The TurboModuleManager then wraps it + * in an ObjCInteropTurboModule internally and sets up its methodQueue via KVC. + * NOTE: This path goes through RCTInstance as a weak delegate intermediary, which + * can become nil — causing a second unconfigured instance. That's why we prefer + * handling ObjC substitutions in getTurboModule:jsInvoker: (priority 1) instead. + * + * PRIORITY 4 — getModuleProvider: + * Legacy/alternative path. Called by some internal flows to get a module instance + * by name string. Similar role to getModuleInstanceFromClass but name-based. + */ -- (id)getModuleProvider:(const char *)name -{ - return _allowedTurboModules.contains(name) ? [super getModuleProvider:name] : nullptr; -} +#pragma mark - RCTTurboModuleManagerDelegate +// PRIORITY 1 - (std::shared_ptr)getTurboModule:(const std::string &)name jsInvoker:(std::shared_ptr)jsInvoker { + auto it = _turboModuleSubstitutions.find(name); + if (it != _turboModuleSubstitutions.end()) { + const std::string &resolvedName = it->second; + + // Try C++ TurboModule first (e.g. codegen-generated spec) + auto cxxModule = [super getTurboModule:resolvedName jsInvoker:jsInvoker]; + if (cxxModule) { + if (auto sandboxAware = std::dynamic_pointer_cast(cxxModule)) { + sandboxAware->configureSandbox({ + .origin = _origin, + .requestedModuleName = name, + .resolvedModuleName = resolvedName, + }); + } + return cxxModule; + } + + return [self _createObjCTurboModuleForSubstitution:name resolvedName:resolvedName jsInvoker:jsInvoker]; + } + if (_allowedTurboModules.contains(name)) { return [super getTurboModule:name jsInvoker:jsInvoker]; - } else { - // Return C++ stub instead of nullptr - return std::make_shared(name, jsInvoker); } + + return std::make_shared(name, jsInvoker); +} + +// PRIORITY 2 +- (Class)getModuleClassFromName:(const char *)name +{ + std::string nameStr(name); + + auto it = _turboModuleSubstitutions.find(nameStr); + if (it != _turboModuleSubstitutions.end()) { + NSString *resolvedName = [NSString stringWithUTF8String:it->second.c_str()]; + for (Class moduleClass in RCTGetModuleClasses()) { + if ([[moduleClass moduleName] isEqualToString:resolvedName]) { + return moduleClass; + } + } + } + + return nullptr; +} + +// PRIORITY 3 +- (id)getModuleInstanceFromClass:(Class)moduleClass +{ + NSString *moduleName = [moduleClass moduleName]; + if (!moduleName) { + return nullptr; + } + + id cached = _substitutedModuleInstances[moduleName]; + if (cached) { + return (id)cached; + } + + std::string moduleNameStr = [moduleName UTF8String]; + bool isSubstitutionTarget = false; + std::string requestedName; + + for (auto &pair : _turboModuleSubstitutions) { + if (pair.second == moduleNameStr) { + isSubstitutionTarget = true; + requestedName = pair.first; + break; + } + } + + if (!isSubstitutionTarget) { + return nullptr; + } + + id module = [moduleClass new]; + + if ([(id)module conformsToProtocol:@protocol(RCTSandboxAwareModule)]) { + NSString *originNS = [NSString stringWithUTF8String:_origin.c_str()]; + NSString *requestedNameNS = [NSString stringWithUTF8String:requestedName.c_str()]; + [(id)module configureSandboxWithOrigin:originNS + requestedName:requestedNameNS + resolvedName:moduleName]; + } + + _substitutedModuleInstances[moduleName] = module; + return (id)module; +} + +// PRIORITY 4 +- (id)getModuleProvider:(const char *)name +{ + std::string nameStr(name); + + auto it = _turboModuleSubstitutions.find(nameStr); + if (it != _turboModuleSubstitutions.end()) { + NSString *resolvedName = [NSString stringWithUTF8String:it->second.c_str()]; + + id cached = _substitutedModuleInstances[resolvedName]; + if (cached) { + return (id)cached; + } + + // Try the dependency provider first (for Codegen TurboModules) + id provider = [super getModuleProvider:it->second.c_str()]; + + if (!provider) { + for (Class moduleClass in RCTGetModuleClasses()) { + if ([[moduleClass moduleName] isEqualToString:resolvedName]) { + provider = [moduleClass new]; + break; + } + } + } + + if (!provider) { + return nullptr; + } + + if ([(id)provider conformsToProtocol:@protocol(RCTSandboxAwareModule)]) { + NSString *originNS = [NSString stringWithUTF8String:_origin.c_str()]; + NSString *requestedNameNS = [NSString stringWithUTF8String:nameStr.c_str()]; + [(id)provider configureSandboxWithOrigin:originNS + requestedName:requestedNameNS + resolvedName:resolvedName]; + } + + if ([(id)provider conformsToProtocol:@protocol(RCTBridgeModule)]) { + _substitutedModuleInstances[resolvedName] = (id)provider; + } + + return provider; + } + + return _allowedTurboModules.contains(nameStr) ? [super getModuleProvider:name] : nullptr; +} + +- (std::shared_ptr) + _createObjCTurboModuleForSubstitution:(const std::string &)requestedName + resolvedName:(const std::string &)resolvedName + jsInvoker:(std::shared_ptr)jsInvoker +{ + NSString *resolvedNameNS = [NSString stringWithUTF8String:resolvedName.c_str()]; + + id cached = _substitutedModuleInstances[resolvedNameNS]; + if (cached && [(id)cached conformsToProtocol:@protocol(RCTTurboModule)]) { + return [self _wrapObjCModule:cached moduleName:requestedName jsInvoker:jsInvoker]; + } + + Class moduleClass = nil; + for (Class cls in RCTGetModuleClasses()) { + if ([[cls moduleName] isEqualToString:resolvedNameNS]) { + moduleClass = cls; + break; + } + } + + if (!moduleClass) { + return nullptr; + } + + id instance = [moduleClass new]; + + if ([(id)instance conformsToProtocol:@protocol(RCTSandboxAwareModule)]) { + NSString *originNS = [NSString stringWithUTF8String:_origin.c_str()]; + NSString *requestedNameNS = [NSString stringWithUTF8String:requestedName.c_str()]; + [(id)instance configureSandboxWithOrigin:originNS + requestedName:requestedNameNS + resolvedName:resolvedNameNS]; + } + + _substitutedModuleInstances[resolvedNameNS] = instance; + + if (![(id)instance conformsToProtocol:@protocol(RCTTurboModule)]) { + return nullptr; + } + + return [self _wrapObjCModule:instance moduleName:requestedName jsInvoker:jsInvoker]; +} + +- (std::shared_ptr)_wrapObjCModule:(id)instance + moduleName:(const std::string &)moduleName + jsInvoker: + (std::shared_ptr)jsInvoker +{ + dispatch_queue_t methodQueue = nil; + BOOL hasMethodQueueGetter = [instance respondsToSelector:@selector(methodQueue)]; + if (hasMethodQueueGetter) { + methodQueue = [instance methodQueue]; + } + + if (!methodQueue) { + NSString *label = [NSString stringWithFormat:@"com.sandbox.%s", moduleName.c_str()]; + methodQueue = dispatch_queue_create(label.UTF8String, DISPATCH_QUEUE_SERIAL); + + if (hasMethodQueueGetter) { + @try { + [(id)instance setValue:methodQueue forKey:@"methodQueue"]; + } @catch (NSException *exception) { + RCTLogError(@"[Sandbox] Failed to set methodQueue on module '%s': %@", moduleName.c_str(), exception.reason); + } + } + } + + auto nativeInvoker = std::make_shared(methodQueue); + + facebook::react::ObjCTurboModule::InitParams params = { + .moduleName = moduleName, + .instance = instance, + .jsInvoker = jsInvoker, + .nativeMethodCallInvoker = nativeInvoker, + .isSyncModule = methodQueue == RCTJSThread, + .shouldVoidMethodsExecuteSync = false, + }; + return [(id)instance getTurboModule:params]; } - (jsi::Function)createPostMessageFunction:(jsi::Runtime &)runtime diff --git a/packages/react-native-sandbox/ios/SandboxReactNativeViewComponentView.mm b/packages/react-native-sandbox/ios/SandboxReactNativeViewComponentView.mm index 9c22f93..cb69086 100644 --- a/packages/react-native-sandbox/ios/SandboxReactNativeViewComponentView.mm +++ b/packages/react-native-sandbox/ios/SandboxReactNativeViewComponentView.mm @@ -94,6 +94,18 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared & [self.reactNativeDelegate setAllowedOrigins:allowedOrigins]; } + if (oldViewProps.turboModuleSubstitutions != newViewProps.turboModuleSubstitutions) { + std::map subs; + if (newViewProps.turboModuleSubstitutions.isObject()) { + for (const auto &pair : newViewProps.turboModuleSubstitutions.items()) { + if (pair.first.isString() && pair.second.isString()) { + subs[pair.first.getString()] = pair.second.getString(); + } + } + } + [self.reactNativeDelegate setTurboModuleSubstitutions:subs]; + } + self.reactNativeDelegate.hasOnMessageHandler = newViewProps.hasOnMessageHandler; self.reactNativeDelegate.hasOnErrorHandler = newViewProps.hasOnErrorHandler; @@ -101,7 +113,14 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared & [self updateEventEmitterIfNeeded]; } - if (oldViewProps.componentName != newViewProps.componentName || + BOOL turboModuleConfigChanged = oldViewProps.allowedTurboModules != newViewProps.allowedTurboModules || + oldViewProps.turboModuleSubstitutions != newViewProps.turboModuleSubstitutions; + + if (turboModuleConfigChanged) { + self.reactNativeFactory = nil; + } + + if (turboModuleConfigChanged || oldViewProps.componentName != newViewProps.componentName || oldViewProps.initialProperties != newViewProps.initialProperties || oldViewProps.launchOptions != newViewProps.launchOptions) { [self scheduleReactViewLoad]; @@ -162,7 +181,6 @@ - (void)loadReactNativeView launchOptions = (NSDictionary *)convertFollyDynamicToId(props.launchOptions); } - // Use existing delegate (properties already updated in updateProps) if (!self.reactNativeFactory) { self.reactNativeFactory = [[RCTReactNativeFactory alloc] initWithDelegate:self.reactNativeDelegate]; } diff --git a/packages/react-native-sandbox/ios/StubTurboModuleCxx.mm b/packages/react-native-sandbox/ios/StubTurboModuleCxx.mm index bed5266..f2eb268 100644 --- a/packages/react-native-sandbox/ios/StubTurboModuleCxx.mm +++ b/packages/react-native-sandbox/ios/StubTurboModuleCxx.mm @@ -12,13 +12,8 @@ jsi::Value StubTurboModuleCxx::get(jsi::Runtime &runtime, const jsi::PropNameID &propName) { - // Get the property name as a string std::string methodName = propName.utf8(runtime); - - // Log the blocked access attempt logBlockedAccess(methodName); - - // Return a stub function that will handle any method calls return createStubFunction(runtime, methodName); } @@ -35,17 +30,24 @@ return jsi::Function::createFromHostFunction( runtime, jsi::PropNameID::forAscii(runtime, methodName.c_str()), - 0, // number of parameters - we accept any number + 0, [this, methodName]( jsi::Runtime &rt, const jsi::Value &thisVal, const jsi::Value *args, size_t count) -> jsi::Value { - // Log the method call attempt using React Native API - RCTLogWarn( - @"[StubTurboModuleCxx] Method call '%s' blocked on module '%s'. This module is blocked as unsafe, please add it to allowedTurboModules in SandboxReactNativeView.", - methodName.c_str(), - this->moduleName_.c_str()); - - // Fail fast - just return undefined for all cases + logBlockedAccess(methodName); +#if DEBUG + auto errorMsg = + [NSString stringWithFormat:@"Module '%s' is blocked. Method '%s' is not available in this sandbox.", + this->moduleName_.c_str(), + methodName.c_str()] + .UTF8String; + auto Promise = rt.global().getPropertyAsFunction(rt, "Promise"); + auto reject = Promise.getPropertyAsFunction(rt, "reject"); + auto Error = rt.global().getPropertyAsFunction(rt, "Error"); + auto error = Error.callAsConstructor(rt, jsi::String::createFromUtf8(rt, errorMsg)); + return reject.callWithThis(rt, Promise, error); +#else return jsi::Value::undefined(); +#endif }); } diff --git a/packages/react-native-sandbox/specs/NativeSandboxReactNativeView.ts b/packages/react-native-sandbox/specs/NativeSandboxReactNativeView.ts index a701aed..4ce4ee4 100644 --- a/packages/react-native-sandbox/specs/NativeSandboxReactNativeView.ts +++ b/packages/react-native-sandbox/specs/NativeSandboxReactNativeView.ts @@ -59,6 +59,13 @@ export interface NativeProps extends ViewProps { /** Array of TurboModule names allowed in the sandbox */ allowedTurboModules?: readonly string[] + /** + * Map of TurboModule substitutions for this sandbox. + * Keys are module names that sandbox JS requests, values are the actual + * native module names to resolve instead. Substituted modules are implicitly allowed. + */ + turboModuleSubstitutions?: CodegenTypes.UnsafeMixed + /** Array of sandbox origins that are allowed to send messages to this sandbox */ allowedOrigins?: readonly string[] diff --git a/packages/react-native-sandbox/src/index.tsx b/packages/react-native-sandbox/src/index.tsx index a0d1058..9bd5ee2 100644 --- a/packages/react-native-sandbox/src/index.tsx +++ b/packages/react-native-sandbox/src/index.tsx @@ -99,6 +99,15 @@ export interface SandboxReactNativeViewProps extends ViewProps { */ allowedTurboModules?: string[] + /** + * Map of TurboModule substitutions for this sandbox instance. + * Keys are the module names that sandbox JS code requests, + * values are the actual native module names to resolve instead. + * Substituted modules are implicitly allowed and don't need to be + * listed in allowedTurboModules. + */ + turboModuleSubstitutions?: Record + /** * Array of sandbox origins that are allowed to send messages to this sandbox. * If not provided or empty, no other sandboxes will be allowed to send messages.