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

-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