luajitos

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs

SafeFS.lua (77255B)


      1 -- SafeFS: Sandboxed Filesystem Access
      2 -- Provides isolated filesystem access with path validation and security
      3 
      4 local SafeFS = {}
      5 SafeFS.__index = SafeFS
      6 SafeFS.__metatable = false  -- Prevent metatable access/modification
      7 
      8 -- Capture C ramdisk functions at module load time (before sandbox restricts access)
      9 local _CRamdiskList = CRamdiskList
     10 local _CRamdiskOpen = CRamdiskOpen
     11 local _CRamdiskRead = CRamdiskRead
     12 local _CRamdiskWrite = CRamdiskWrite
     13 local _CRamdiskClose = CRamdiskClose
     14 local _CRamdiskExists = CRamdiskExists
     15 local _CRamdiskMkdir = CRamdiskMkdir
     16 local _CRamdiskRemove = CRamdiskRemove
     17 
     18 -- Internal file handler registry (not accessible from sandbox)
     19 -- Format: { ["txt"] = { appId = "com.luajitos.lunareditor", functionName = "openFile" }, ... }
     20 -- This table is stored at module level, not on instances, so it persists across SafeFS instances
     21 -- and cannot be accessed by sandboxed code
     22 local _fileHandlers = {}
     23 
     24 -- Helper: Split path into components
     25 local function splitPath(path)
     26     local parts = {}
     27     for part in string.gmatch(path, "[^/]+") do
     28         table.insert(parts, part)
     29     end
     30     return parts
     31 end
     32 
     33 -- Helper: Join path components
     34 local function joinPath(parts)
     35     if #parts == 0 then
     36         return "/"
     37     end
     38     return "/" .. table.concat(parts, "/")
     39 end
     40 
     41 -- Helper: Check if a path is within allowed bounds
     42 local function isPathAllowed(path, allowedRoots)
     43     for _, root in ipairs(allowedRoots) do
     44         -- Check exact match
     45         if path == root then
     46             return true
     47         end
     48         -- Check if path is under this root
     49         if path:sub(1, #root + 1) == root .. "/" then
     50             return true
     51         end
     52         -- Check if path is a parent of this root (needed for .. navigation)
     53         -- For example, if allowed is /apps/com.example/data, we should allow /apps/com.example
     54         if root:sub(1, #path + 1) == path .. "/" then
     55             return true
     56         end
     57     end
     58     return false
     59 end
     60 
     61 -- Helper: Normalize path (resolve . and .., but only within SafeFS bounds)
     62 local function normalizePath(path, allowedRoots)
     63     -- Remove leading/trailing whitespace
     64     path = path:match("^%s*(.-)%s*$")
     65 
     66     -- Handle empty path
     67     if path == "" or path == "/" then
     68         return "/"
     69     end
     70 
     71     -- Ensure path starts with /
     72     if path:sub(1, 1) ~= "/" then
     73         path = "/" .. path
     74     end
     75 
     76     local parts = splitPath(path)
     77     local normalized = {}
     78 
     79     for _, part in ipairs(parts) do
     80         if part == "." then
     81             -- Skip current directory references
     82         elseif part == ".." then
     83             -- Go up one level, but check if parent is allowed
     84             if #normalized > 0 then
     85                 -- Build parent path to check if it's allowed
     86                 local tempNormalized = {}
     87                 for i = 1, #normalized - 1 do
     88                     tempNormalized[i] = normalized[i]
     89                 end
     90                 local parentPath = joinPath(tempNormalized)
     91 
     92                 -- Only go up if parent is allowed
     93                 if isPathAllowed(parentPath, allowedRoots) then
     94                     table.remove(normalized)
     95                 else
     96                     -- Parent not allowed, ignore the ..
     97                     -- This prevents escaping the allowed directory tree
     98                 end
     99             end
    100         else
    101             table.insert(normalized, part)
    102         end
    103     end
    104 
    105     local result = joinPath(normalized)
    106 
    107     -- Verify the normalized path is within allowed bounds
    108     if not isPathAllowed(result, allowedRoots) then
    109         return nil, "Path outside allowed directories: " .. result
    110     end
    111 
    112     return result
    113 end
    114 
    115 -- Helper: Check if path matches pattern (supports * wildcard)
    116 local function matchesPattern(path, pattern)
    117     -- Handle exact match
    118     if path == pattern then
    119         return true
    120     end
    121 
    122     -- Handle wildcard patterns like "/tmp/*"
    123     if pattern:sub(-2) == "/*" then
    124         local prefix = pattern:sub(1, -3)
    125         if path == prefix then
    126             return true
    127         end
    128         if path:sub(1, #prefix + 1) == prefix .. "/" then
    129             return true
    130         end
    131     end
    132 
    133     return false
    134 end
    135 
    136 -- Helper: Check if a path would clash with a pseudo file
    137 local function checkPseudoFileClash(parentDir, filename)
    138     if not parentDir or not parentDir.files then
    139         return false, nil
    140     end
    141 
    142     -- Check if there's a pseudo file with this exact name
    143     for _, file in ipairs(parentDir.files) do
    144         if file.isPseudo and file.name == filename then
    145             return true, file
    146         end
    147     end
    148 
    149     -- Check if filename looks like it has arguments (matches "pseudofile .*")
    150     local spacePos = filename:find(" ")
    151     if spacePos then
    152         local baseName = filename:sub(1, spacePos - 1)
    153         for _, file in ipairs(parentDir.files) do
    154             if file.isPseudo and file.name == baseName then
    155                 return true, file
    156             end
    157         end
    158     end
    159 
    160     return false, nil
    161 end
    162 
    163 -- Helper: Check if creating at this path would place something directly in root
    164 -- Returns true if the path's parent is "/" (the root directory)
    165 local function isDirectlyInRoot(path)
    166     if not path or path == "/" then
    167         return false
    168     end
    169 
    170     local parts = splitPath(path)
    171     -- If there's only one component (e.g., "/newfile" or "/newdir"),
    172     -- then parent is root
    173     return #parts == 1
    174 end
    175 
    176 -- Helper: Deep copy a node and isolate it (remove external references)
    177 local function deepCopyNode(node, parentNode, allowedPath)
    178     if not node then
    179         return nil
    180     end
    181 
    182     local copy = {
    183         name = node.name,
    184         type = node.type,
    185         parent = parentNode  -- Set to new parent, not original
    186     }
    187 
    188     if node.type == "directory" then
    189         copy.dirs = {}
    190         copy.files = {}
    191 
    192         -- Copy child directories
    193         if node.dirs then
    194             for _, dir in ipairs(node.dirs) do
    195                 local childPath = allowedPath .. "/" .. dir.name
    196                 local dirCopy = deepCopyNode(dir, copy, childPath)
    197                 if dirCopy then
    198                     table.insert(copy.dirs, dirCopy)
    199                 end
    200             end
    201         end
    202 
    203         -- Copy child files
    204         if node.files then
    205             for _, file in ipairs(node.files) do
    206                 local fileCopy = {
    207                     name = file.name,
    208                     type = "file",
    209                     parent = copy,
    210                     content = file.content  -- Reference to content (not copied)
    211                 }
    212                 table.insert(copy.files, fileCopy)
    213             end
    214         end
    215     elseif node.type == "file" then
    216         copy.content = node.content  -- Reference to content
    217     end
    218 
    219     return copy
    220 end
    221 
    222 -- Helper: Find node in tree (for original rootNode traversal)
    223 local function findNode(rootNode, path)
    224     -- Check if rootNode has a traverse function (C-based filesystem)
    225     if type(rootNode) == "table" and type(rootNode.traverse) == "function" then
    226         -- Use the traverse function
    227         local node, err = rootNode:traverse(path)
    228         if not node then
    229             return nil, err or "Path not found"
    230         end
    231         return node
    232     end
    233 
    234     -- Fall back to manual traversal for Lua-based filesystem nodes
    235     if path == "/" then
    236         -- Find actual root
    237         local root = rootNode
    238         while root.parent do
    239             root = root.parent
    240         end
    241         return root
    242     end
    243 
    244     local parts = splitPath(path)
    245     local current = rootNode
    246 
    247     -- Find root first
    248     while current.parent do
    249         current = current.parent
    250     end
    251 
    252     if osprint then
    253         osprint("[SafeFS.findNode] Starting traversal for path: " .. path .. "\n")
    254         osprint("[SafeFS.findNode] Path parts: " .. table.concat(parts, ", ") .. "\n")
    255         osprint("[SafeFS.findNode] Starting at root: " .. tostring(current.name or "/") .. "\n")
    256     end
    257 
    258     -- Traverse path
    259     for i, part in ipairs(parts) do
    260         if osprint then
    261             osprint("[SafeFS.findNode] Step " .. i .. ": Looking for '" .. part .. "' in current node\n")
    262             osprint("[SafeFS.findNode]   Current node type: " .. tostring(current.type) .. "\n")
    263             osprint("[SafeFS.findNode]   Current node name: " .. tostring(current.name) .. "\n")
    264         end
    265 
    266         if current.type ~= "directory" then
    267             if osprint then
    268                 osprint("[SafeFS.findNode]   ERROR: Current node is not a directory!\n")
    269             end
    270             return nil, "Not a directory"
    271         end
    272 
    273         local found = false
    274 
    275         -- Check directories
    276         if current.dirs then
    277             if osprint then
    278                 osprint("[SafeFS.findNode]   Checking " .. #current.dirs .. " subdirectories\n")
    279             end
    280             for _, dir in ipairs(current.dirs) do
    281                 -- Strip leading slash from node name for comparison
    282                 local nodeName = dir.name
    283                 if nodeName:sub(1, 1) == "/" then
    284                     nodeName = nodeName:sub(2)
    285                 end
    286 
    287                 if osprint then
    288                     osprint("[SafeFS.findNode]     - " .. tostring(dir.name) .. " (comparing as: " .. nodeName .. ")\n")
    289                 end
    290 
    291                 if nodeName == part then
    292                     current = dir
    293                     found = true
    294                     if osprint then
    295                         osprint("[SafeFS.findNode]   FOUND! Moving to: " .. tostring(dir.name) .. "\n")
    296                     end
    297                     break
    298                 end
    299             end
    300         end
    301 
    302         -- Check files (only for last component)
    303         if not found and i == #parts and current.files then
    304             for _, file in ipairs(current.files) do
    305                 -- Strip leading slash from node name for comparison
    306                 local fileName = file.name
    307                 if fileName:sub(1, 1) == "/" then
    308                     fileName = fileName:sub(2)
    309                 end
    310 
    311                 if fileName == part then
    312                     return file
    313                 end
    314             end
    315         end
    316 
    317         if not found then
    318             if osprint then
    319                 osprint("[SafeFS.findNode]   NOT FOUND: Could not find '" .. part .. "'\n")
    320             end
    321             return nil, "Path not found: " .. part
    322         end
    323     end
    324 
    325     if osprint then
    326         osprint("[SafeFS.findNode] Traversal complete! Reached: " .. tostring(current.name) .. "\n")
    327     end
    328 
    329     return current
    330 end
    331 
    332 -- Helper: Find node in SafeFS isolated tree
    333 local function findInSafeFS(safeFS, path)
    334     if osprint then
    335         osprint("[findInSafeFS] Looking for path: " .. path .. "\n")
    336         osprint("[findInSafeFS] Available roots:\n")
    337         for rootPath, rootNode in pairs(safeFS.roots) do
    338             osprint("  " .. rootPath .. " -> " .. tostring(rootNode) .. "\n")
    339         end
    340     end
    341 
    342     if path == "/" then
    343         return nil, "Root directory not accessible"
    344     end
    345 
    346     -- Find which root this path belongs to
    347     local matchedRoot = nil
    348     local matchedNode = nil
    349 
    350     for rootPath, rootNode in pairs(safeFS.roots) do
    351         if path == rootPath then
    352             if osprint then
    353                 osprint("[findInSafeFS] Exact match! Returning root node for " .. rootPath .. "\n")
    354             end
    355             return rootNode
    356         end
    357         if path:sub(1, #rootPath + 1) == rootPath .. "/" then
    358             matchedRoot = rootPath
    359             matchedNode = rootNode
    360             if osprint then
    361                 osprint("[findInSafeFS] Partial match! " .. path .. " starts with " .. rootPath .. "/\n")
    362             end
    363             break
    364         end
    365     end
    366 
    367     if not matchedNode then
    368         if osprint then
    369             osprint("[findInSafeFS] No matching root found for " .. path .. "\n")
    370         end
    371         return nil, "Path not in SafeFS"
    372     end
    373 
    374     -- Get relative path from root
    375     local relativePath = path:sub(#matchedRoot + 2)  -- +2 to skip the /
    376     if relativePath == "" then
    377         return matchedNode
    378     end
    379 
    380     local parts = splitPath(relativePath)
    381     local current = matchedNode
    382 
    383     for i, part in ipairs(parts) do
    384         if current.type ~= "directory" then
    385             return nil, "Not a directory"
    386         end
    387 
    388         local found = false
    389 
    390         -- Check directories in node.dirs array
    391         if current.dirs then
    392             for _, dir in ipairs(current.dirs) do
    393                 -- Strip leading slash for comparison
    394                 local dirName = dir.name
    395                 if dirName:sub(1, 1) == "/" then
    396                     dirName = dirName:sub(2)
    397                 end
    398                 if dirName == part then
    399                     -- Check if this is a pseudo directory with onTraverse
    400                     if dir.isPseudo and dir.onTraverse then
    401                         -- Build the remaining relative path
    402                         local remainingParts = {}
    403                         for j = i + 1, #parts do
    404                             table.insert(remainingParts, parts[j])
    405                         end
    406                         local remainingPath = table.concat(remainingParts, "/")
    407 
    408                         -- Call onTraverse with the remaining path
    409                         local result = dir.onTraverse(remainingPath)
    410                         if result then
    411                             -- If there's no remaining path, return the result
    412                             if remainingPath == "" then
    413                                 return result
    414                             end
    415                             -- Otherwise, onTraverse handled the full traversal
    416                             return result
    417                         end
    418                     end
    419                     current = dir
    420                     found = true
    421                     break
    422                 end
    423             end
    424         end
    425 
    426         -- If not found and node.dirs is nil, try _CRamdiskList fallback
    427         if not found and not current.dirs and _CRamdiskList then
    428             -- Build the full path up to this point
    429             local pathSoFar = matchedRoot
    430             for j = 1, i - 1 do
    431                 pathSoFar = pathSoFar .. "/" .. parts[j]
    432             end
    433 
    434             if osprint then
    435                 osprint("[findInSafeFS] Using _CRamdiskList to find '" .. part .. "' in " .. pathSoFar .. "\n")
    436             end
    437 
    438             local items = _CRamdiskList(pathSoFar)
    439             if items then
    440                 for _, item in ipairs(items) do
    441                     -- Strip leading slash from name
    442                     local itemName = item.name
    443                     if itemName:sub(1, 1) == "/" then
    444                         itemName = itemName:sub(2)
    445                     end
    446 
    447                     if itemName == part then
    448                         if item.type == "directory" or item.type == "dir" then
    449                             -- For _CRamdiskList results, we need to use traverse to get the actual node
    450                             local fullPath = pathSoFar .. "/" .. part
    451                             if osprint then
    452                                 osprint("[findInSafeFS] Found directory via _CRamdiskList, using traverse for: " .. fullPath .. "\n")
    453                             end
    454                             -- Use the global root_fs or safeFS.rootNode to traverse
    455                             local rootFs = safeFS.rootNode or _G.root_fs
    456                             if rootFs and rootFs.traverse then
    457                                 local node, err = rootFs:traverse(fullPath)
    458                                 if node then
    459                                     current = node
    460                                     found = true
    461                                 else
    462                                     -- Create a minimal directory node for traversal
    463                                     current = { name = part, type = "directory", path = fullPath }
    464                                     found = true
    465                                 end
    466                             else
    467                                 -- Create a minimal directory node for traversal
    468                                 current = { name = part, type = "directory", path = fullPath }
    469                                 found = true
    470                             end
    471                         elseif i == #parts and (item.type == "file") then
    472                             -- For files, construct a minimal file node
    473                             return { name = itemName, type = "file", path = pathSoFar .. "/" .. part }
    474                         end
    475                         break
    476                     end
    477                 end
    478             end
    479         end
    480 
    481         -- Check files (only for last component)
    482         if not found and i == #parts and current.files then
    483             for _, file in ipairs(current.files) do
    484                 -- Strip leading slash for comparison
    485                 local fileName = file.name
    486                 if fileName:sub(1, 1) == "/" then
    487                     fileName = fileName:sub(2)
    488                 end
    489                 if fileName == part then
    490                     return file
    491                 end
    492             end
    493         end
    494 
    495         if not found then
    496             if osprint then
    497                 osprint("[findInSafeFS] Could not find '" .. part .. "' in current node\n")
    498             end
    499             return nil, "Path not found: " .. part
    500         end
    501     end
    502 
    503     return current
    504 end
    505 
    506 -- Helper: Ensure directory exists in SafeFS
    507 local function ensureDirectory(safeFS, path)
    508     local parts = splitPath(path)
    509 
    510     -- Find which root this belongs to
    511     local matchedRoot = nil
    512     local matchedNode = nil
    513 
    514     for rootPath, rootNode in pairs(safeFS.roots) do
    515         if path == rootPath or path:sub(1, #rootPath + 1) == rootPath .. "/" then
    516             matchedRoot = rootPath
    517             matchedNode = rootNode
    518             break
    519         end
    520     end
    521 
    522     if not matchedNode then
    523         return nil, "Path not in SafeFS"
    524     end
    525 
    526     -- Get relative path
    527     local relativePath = path:sub(#matchedRoot + 2)
    528     if relativePath == "" or relativePath == "/" then
    529         return matchedNode
    530     end
    531 
    532     local relParts = splitPath(relativePath)
    533     local current = matchedNode
    534 
    535     for _, part in ipairs(relParts) do
    536         if current.type ~= "directory" then
    537             return nil, "Not a directory"
    538         end
    539 
    540         -- Look for existing directory
    541         local found = false
    542         if current.dirs then
    543             for _, dir in ipairs(current.dirs) do
    544                 if dir.name == part then
    545                     current = dir
    546                     found = true
    547                     break
    548                 end
    549             end
    550         end
    551 
    552         -- Create if not found
    553         if not found then
    554             if not current.dirs then
    555                 current.dirs = {}
    556             end
    557 
    558             local newDir = {
    559                 name = part,
    560                 type = "directory",
    561                 parent = current,
    562                 dirs = {},
    563                 files = {}
    564             }
    565             table.insert(current.dirs, newDir)
    566             current = newDir
    567         end
    568     end
    569 
    570     return current
    571 end
    572 
    573 -- Internal registry for SafeFS privileged functions (stored in _G, not exposed to sandbox)
    574 -- Key is the SafeFS instance, value is a table of internal functions
    575 if not _G._safefsInternal then
    576     _G._safefsInternal = setmetatable({}, {__mode = "k"})  -- Weak keys so instances can be GC'd
    577 end
    578 
    579 -- Dynamically add an allowed path to a SafeFS instance
    580 -- This is stored in _G._safefsInternal, not on the instance itself, so sandbox can't access it
    581 local function _addAllowedPath(self, path)
    582     if not path or type(path) ~= "string" then
    583         return false, "Invalid path"
    584     end
    585 
    586     -- Handle wildcard suffix
    587     local isWildcard = path:sub(-2) == "/*"
    588     local cleanPath = isWildcard and path:sub(1, -3) or path
    589 
    590     -- Remove trailing slash if present
    591     if cleanPath ~= "/" and cleanPath:sub(-1) == "/" then
    592         cleanPath = cleanPath:sub(1, -2)
    593     end
    594 
    595     -- Check if already allowed
    596     if self.roots[cleanPath] then
    597         return true, "Already allowed"
    598     end
    599 
    600     -- For diskfs paths, create a PseudoDir mount if needed
    601     local isDiskFS = cleanPath:match("^/mnt/hd%d")
    602     if isDiskFS and self.createDiskFSMount then
    603         -- Extract bus/drive from path like /mnt/hd0
    604         local hdNum = tonumber(cleanPath:match("^/mnt/hd(%d)"))
    605         if hdNum then
    606             -- Try to mount the disk
    607             local mountPath = "/mnt/hd" .. hdNum
    608             if not self.roots[mountPath] then
    609                 local pseudoDir, err = self:createDiskFSMount(mountPath, 0, hdNum)
    610                 if pseudoDir then
    611                     if osprint then
    612                         osprint("[SafeFS] Dynamically mounted disk at " .. mountPath .. "\n")
    613                     end
    614                     return true
    615                 else
    616                     if osprint then
    617                         osprint("[SafeFS] Failed to mount disk: " .. tostring(err) .. "\n")
    618                     end
    619                 end
    620             else
    621                 return true, "Disk already mounted"
    622             end
    623         end
    624     end
    625 
    626     -- Try to find the node in the root filesystem
    627     local node, err = findNode(self.rootNode, cleanPath)
    628     if node and node.type == "directory" then
    629         self.roots[cleanPath] = node
    630         table.insert(self.allowedRoots, cleanPath)
    631         table.insert(self.allowedDirs, path)
    632         if osprint then
    633             osprint("[SafeFS] Dynamically added allowed path: " .. cleanPath .. "\n")
    634         end
    635         return true
    636     end
    637 
    638     return false, err or "Path not found"
    639 end
    640 
    641 -- Create a new SafeFS instance
    642 -- rootNode: The root filesystem node
    643 -- allowedDirs: List of directory patterns (e.g., {"/tmp/*", "/apps/com.dev.app/data/*"})
    644 -- currentUser: Optional username for ~ expansion (default: "root")
    645 -- appId: Optional app identifier for file handler registration and $ expansion (e.g., "com.luajitos.lunareditor")
    646 -- appPath: Optional app path for $ expansion (e.g., "/apps/com.luajitos.lunareditor")
    647 -- Returns: SafeFS instance, or nil and error message on failure
    648 function SafeFS.new(rootNode, allowedDirs, currentUser, appId, appPath)
    649     if osprint then osprint("SafeFS.new: ENTERED\n") end
    650 
    651     if not rootNode then
    652         if osprint then osprint("SafeFS.new: rootNode is nil, returning error\n") end
    653         return nil, "rootNode is required"
    654     end
    655 
    656     if osprint then osprint("SafeFS.new: Creating self object\n") end
    657     local self = setmetatable({}, SafeFS)
    658     if osprint then osprint("SafeFS.new: Self created\n") end
    659 
    660     self.rootNode = rootNode
    661     self.allowedDirs = allowedDirs or {}
    662     self.currentUser = currentUser or "root"
    663     self._appId = appId  -- Store app ID for file handler registration (internal, not exposed)
    664     self._appPath = appPath or (appId and ("/apps/" .. appId) or nil)  -- App path for $ expansion
    665     self.cwd = "/"  -- Current working directory
    666     self.roots = {}  -- Isolated root nodes for each allowed directory
    667     self.allowedRoots = {}  -- List of root paths for validation
    668 
    669     -- Automatically add $/data/* access if appPath is set
    670     if self._appPath then
    671         local dataPath = self._appPath .. "/data/*"
    672         local hasDataPath = false
    673         for _, pattern in ipairs(self.allowedDirs) do
    674             if pattern == dataPath or pattern == self._appPath .. "/data" then
    675                 hasDataPath = true
    676                 break
    677             end
    678         end
    679         if not hasDataPath then
    680             table.insert(self.allowedDirs, dataPath)
    681             if osprint then osprint("SafeFS.new: Auto-added $/data/* access: " .. dataPath .. "\n") end
    682         end
    683     end
    684 
    685     if osprint then osprint("SafeFS.new: Processing allowed directories (" .. #self.allowedDirs .. " patterns)\n") end
    686 
    687     -- Helper to add a path to roots
    688     local function addRoot(cleanPath)
    689         -- Check if already added
    690         if self.roots[cleanPath] then
    691             return true
    692         end
    693 
    694         local node, err = findNode(rootNode, cleanPath)
    695         if node and node.type == "directory" then
    696             self.roots[cleanPath] = node
    697             table.insert(self.allowedRoots, cleanPath)
    698             if osprint then osprint("SafeFS: Added allowed root: " .. cleanPath .. "\n") end
    699             return true
    700         end
    701         return false
    702     end
    703 
    704     -- Process allowed directories and create isolated trees
    705     local foundAtLeastOne = false
    706     for i, pattern in ipairs(self.allowedDirs) do
    707         if osprint then osprint("SafeFS.new: Processing pattern " .. i .. ": " .. pattern .. "\n") end
    708 
    709         -- Check if pattern ends with /* (wildcard)
    710         local isWildcard = pattern:sub(-2) == "/*"
    711 
    712         -- Remove /* suffix if present
    713         local cleanPath = pattern
    714         if isWildcard then
    715             cleanPath = cleanPath:sub(1, -3)
    716         end
    717 
    718         -- Handle ~ expansion
    719         if cleanPath:sub(1, 2) == "~/" then
    720             cleanPath = "/home/" .. self.currentUser .. cleanPath:sub(2)
    721         elseif cleanPath == "~" then
    722             cleanPath = "/home/" .. self.currentUser
    723         end
    724 
    725         -- Handle $ expansion
    726         if self._appPath then
    727             if cleanPath:sub(1, 2) == "$/" then
    728                 cleanPath = self._appPath .. cleanPath:sub(2)
    729             elseif cleanPath == "$" then
    730                 cleanPath = self._appPath
    731             end
    732         end
    733 
    734         -- Find node in original filesystem
    735         if osprint then osprint("SafeFS.new: Finding node for: " .. cleanPath .. "\n") end
    736 
    737         if addRoot(cleanPath) then
    738             foundAtLeastOne = true
    739         else
    740             if osprint then osprint("SafeFS: Failed to find directory: " .. cleanPath .. "\n") end
    741         end
    742     end
    743 
    744     -- Return error if no allowed directories were found
    745     if not foundAtLeastOne and #self.allowedDirs > 0 then
    746         return nil, "No allowed directories found"
    747     end
    748 
    749     -- Register internal functions for this instance (accessible via _G._safefsInternal)
    750     _G._safefsInternal[self] = {
    751         addAllowedPath = function(path) return _addAllowedPath(self, path) end
    752     }
    753 
    754     return self
    755 end
    756 
    757 -- Resolve path with ~, $ expansion and CWD support
    758 function SafeFS:resolvePath(path)
    759     -- Handle ~ expansion (home directory)
    760     if path:sub(1, 2) == "~/" then
    761         path = "/home/" .. self.currentUser .. path:sub(2)
    762     elseif path == "~" then
    763         path = "/home/" .. self.currentUser
    764     end
    765 
    766     -- Handle $ expansion (app directory)
    767     if self._appPath then
    768         if path:sub(1, 2) == "$/" then
    769             path = self._appPath .. path:sub(2)
    770         elseif path == "$" then
    771             path = self._appPath
    772         end
    773     end
    774 
    775     -- Handle relative paths (paths that don't start with /)
    776     if path:sub(1, 1) ~= "/" then
    777         -- Make path relative to CWD
    778         local cwd = self.cwd or "/"
    779         -- Ensure CWD ends without trailing slash for joining
    780         if cwd ~= "/" and cwd:sub(-1) == "/" then
    781             cwd = cwd:sub(1, -2)
    782         end
    783         -- Join CWD and relative path
    784         if cwd == "/" then
    785             path = "/" .. path
    786         else
    787             path = cwd .. "/" .. path
    788         end
    789     end
    790 
    791     -- Normalize path
    792     return normalizePath(path, self.allowedRoots)
    793 end
    794 
    795 -- Get current working directory
    796 function SafeFS:getCWD()
    797     return self.cwd
    798 end
    799 
    800 -- Set current working directory
    801 function SafeFS:setCWD(path)
    802     -- Resolve the path first (this already validates against allowed roots)
    803     local resolvedPath, err = self:resolvePath(path)
    804     if not resolvedPath then
    805         return false, err
    806     end
    807 
    808     -- Additional check: ensure the resolved path is actually allowed
    809     -- This catches edge cases where path resolution might succeed but access shouldn't
    810     local pathAllowed = false
    811     for _, root in ipairs(self.allowedRoots) do
    812         if resolvedPath == root or resolvedPath:sub(1, #root + 1) == root .. "/" then
    813             pathAllowed = true
    814             break
    815         end
    816         -- Also allow parent directories if they contain an allowed root
    817         if root:sub(1, #resolvedPath + 1) == resolvedPath .. "/" then
    818             pathAllowed = true
    819             break
    820         end
    821     end
    822 
    823     if not pathAllowed then
    824         return false, "Path not in allowed directories"
    825     end
    826 
    827     -- Check if path exists and is a directory
    828     local node, err = findInSafeFS(self, resolvedPath)
    829     if not node then
    830         return false, err or "Directory not found"
    831     end
    832 
    833     if node.type ~= "directory" then
    834         return false, "Not a directory"
    835     end
    836 
    837     -- Set the CWD
    838     self.cwd = resolvedPath
    839     return true
    840 end
    841 
    842 -- Read file contents
    843 function SafeFS:read(path)
    844     -- Check if path contains arguments (space after filename)
    845     local filePath = path
    846     local args = nil
    847     local spacePos = path:find(" ")
    848 
    849     if spacePos then
    850         filePath = path:sub(1, spacePos - 1)
    851         args = path:sub(spacePos + 1)
    852     end
    853 
    854     local resolvedPath, err = self:resolvePath(filePath)
    855     if not resolvedPath then
    856         return nil, err
    857     end
    858 
    859     local node, err = findInSafeFS(self, resolvedPath)
    860     if not node then
    861         return nil, err or "File not found"
    862     end
    863 
    864     if node.type ~= "file" then
    865         return nil, "Not a file"
    866     end
    867 
    868     -- Handle pseudo files with onRead callback
    869     if node.isPseudo then
    870         if node.onRead then
    871             -- Pass arguments to onRead if present
    872             if args then
    873                 return node.onRead(args)
    874             else
    875                 return node.onRead()
    876             end
    877         else
    878             return nil, "Pseudo file has no onRead callback"
    879         end
    880     end
    881 
    882     -- If node has content, return it
    883     if node.content then
    884         return node.content
    885     end
    886 
    887     -- If node has a path but no content, read from CRamdisk
    888     if node.path and _CRamdiskOpen and _CRamdiskRead and _CRamdiskClose then
    889         local handle = _CRamdiskOpen(node.path, "r")
    890         if handle then
    891             local content = _CRamdiskRead(handle)
    892             _CRamdiskClose(handle)
    893             return content
    894         else
    895             return nil, "Failed to open file: " .. node.path
    896         end
    897     end
    898 
    899     -- Try to read using the resolved path directly
    900     if _CRamdiskOpen and _CRamdiskRead and _CRamdiskClose then
    901         local handle = _CRamdiskOpen(resolvedPath, "r")
    902         if handle then
    903             local content = _CRamdiskRead(handle)
    904             _CRamdiskClose(handle)
    905             return content
    906         end
    907     end
    908 
    909     return nil, "File has no content"
    910 end
    911 
    912 -- Write file contents (creates file if it doesn't exist)
    913 function SafeFS:write(path, content)
    914     local resolvedPath, err = self:resolvePath(path)
    915     if not resolvedPath then
    916         return false, err
    917     end
    918 
    919     -- Protect root directory - cannot create files directly in root
    920     if isDirectlyInRoot(resolvedPath) then
    921         return false, "Cannot create files in root directory"
    922     end
    923 
    924     -- Split path into directory and filename
    925     local parts = splitPath(resolvedPath)
    926     local filename = parts[#parts]
    927     table.remove(parts)
    928     local dirPath = joinPath(parts)
    929 
    930     -- Ensure parent directory exists
    931     local parentDir, err = ensureDirectory(self, dirPath)
    932     if not parentDir then
    933         return false, err or "Parent directory not found"
    934     end
    935 
    936     -- Look for existing file
    937     if parentDir.files then
    938         for _, file in ipairs(parentDir.files) do
    939             if file.name == filename then
    940                 -- Handle pseudo files with onWrite callback
    941                 if file.isPseudo then
    942                     if file.onWrite then
    943                         file.onWrite(content)
    944                         return true
    945                     else
    946                         return false, "Pseudo file has no onWrite callback"
    947                     end
    948                 end
    949 
    950                 -- Update existing file
    951                 file.content = content
    952                 -- Also update in C ramdisk
    953                 if _CRamdiskOpen and _CRamdiskWrite and _CRamdiskClose then
    954                     local handle = _CRamdiskOpen(resolvedPath, "w")
    955                     if handle then
    956                         _CRamdiskWrite(handle, content)
    957                         _CRamdiskClose(handle)
    958                     end
    959                 end
    960                 return true
    961             end
    962         end
    963     end
    964 
    965     -- Check if creating this file would clash with a pseudo file
    966     local clashes, pseudoFile = checkPseudoFileClash(parentDir, filename)
    967     if clashes then
    968         return false, "Cannot create file: would clash with pseudo file '" .. pseudoFile.name .. "'"
    969     end
    970 
    971     -- Create new file in SafeFS tree
    972     if not parentDir.files then
    973         parentDir.files = {}
    974     end
    975 
    976     local newFile = {
    977         name = filename,
    978         type = "file",
    979         parent = parentDir,
    980         content = content
    981     }
    982     table.insert(parentDir.files, newFile)
    983 
    984     -- Also write to C ramdisk so other apps can see it
    985     if _CRamdiskOpen and _CRamdiskWrite and _CRamdiskClose then
    986         local handle, err = _CRamdiskOpen(resolvedPath, "w")
    987         if handle then
    988             _CRamdiskWrite(handle, content)
    989             _CRamdiskClose(handle)
    990             if osprint then
    991                 osprint("[SafeFS] Wrote to C ramdisk: " .. resolvedPath .. "\n")
    992             end
    993         else
    994             if osprint then
    995                 osprint("[SafeFS] Failed to open for write: " .. resolvedPath .. " err=" .. tostring(err) .. "\n")
    996             end
    997         end
    998     end
    999 
   1000     return true
   1001 end
   1002 
   1003 -- Create a default pseudo file handle with read/write/seek/close methods
   1004 -- This is returned when onOpen is not set on a pseudo file
   1005 local function createDefaultPseudoHandle(pseudoFile, path, mode)
   1006     local handle = {
   1007         _pseudoFile = pseudoFile,
   1008         _path = path,
   1009         _mode = mode,
   1010         _content = "",
   1011         _pos = 1,
   1012         _size = 0
   1013     }
   1014 
   1015     -- Initialize content for read modes
   1016     if mode == "r" or mode == "r+" then
   1017         if pseudoFile.onRead then
   1018             handle._content = pseudoFile.onRead() or ""
   1019         end
   1020         handle._size = #handle._content
   1021     elseif mode == "a" or mode == "a+" then
   1022         -- Append mode - get existing content first
   1023         if pseudoFile.onRead then
   1024             handle._content = pseudoFile.onRead() or ""
   1025         end
   1026         handle._pos = #handle._content + 1
   1027         handle._size = #handle._content
   1028     else
   1029         -- Write mode - start empty
   1030         handle._content = ""
   1031         handle._size = 0
   1032     end
   1033 
   1034     function handle:read(format)
   1035         if self._mode == "w" or self._mode == "a" then
   1036             return nil, "Cannot read in write-only mode"
   1037         end
   1038 
   1039         format = format or "*l"
   1040 
   1041         if format == "*a" or format == "*all" then
   1042             local result = self._content:sub(self._pos)
   1043             self._pos = #self._content + 1
   1044             return result
   1045         elseif format == "*l" or format == "*line" then
   1046             local start = self._pos
   1047             local lineEnd = self._content:find("\n", start, true)
   1048             if lineEnd then
   1049                 local line = self._content:sub(start, lineEnd - 1)
   1050                 self._pos = lineEnd + 1
   1051                 return line
   1052             else
   1053                 if self._pos <= #self._content then
   1054                     local line = self._content:sub(start)
   1055                     self._pos = #self._content + 1
   1056                     return line
   1057                 else
   1058                     return nil
   1059                 end
   1060             end
   1061         elseif type(format) == "number" then
   1062             if self._pos > #self._content then
   1063                 return nil
   1064             end
   1065             local result = self._content:sub(self._pos, self._pos + format - 1)
   1066             self._pos = self._pos + format
   1067             return result
   1068         end
   1069     end
   1070 
   1071     function handle:write(data)
   1072         if self._mode == "r" then
   1073             return nil, "Cannot write in read-only mode"
   1074         end
   1075 
   1076         data = tostring(data)
   1077 
   1078         if self._mode == "a" or self._mode == "a+" then
   1079             -- Append mode - always add to end
   1080             self._content = self._content .. data
   1081             self._pos = #self._content + 1
   1082         else
   1083             -- Write mode or r+ - insert at current position
   1084             local before = self._content:sub(1, self._pos - 1)
   1085             local after = self._content:sub(self._pos + #data)
   1086             self._content = before .. data .. after
   1087             self._pos = self._pos + #data
   1088         end
   1089 
   1090         self._size = #self._content
   1091         return true
   1092     end
   1093 
   1094     function handle:seek(whence, offset)
   1095         whence = whence or "cur"
   1096         offset = offset or 0
   1097 
   1098         local newPos
   1099         if whence == "set" then
   1100             newPos = offset + 1
   1101         elseif whence == "cur" then
   1102             newPos = self._pos + offset
   1103         elseif whence == "end" then
   1104             newPos = #self._content + offset + 1
   1105         else
   1106             return nil, "Invalid whence: " .. tostring(whence)
   1107         end
   1108 
   1109         if newPos < 1 then
   1110             newPos = 1
   1111         end
   1112 
   1113         self._pos = newPos
   1114         return self._pos - 1  -- Return 0-based position like Lua's file:seek()
   1115     end
   1116 
   1117     function handle:close()
   1118         -- Write content back via onWrite callback
   1119         if self._mode ~= "r" and self._pseudoFile.onWrite then
   1120             self._pseudoFile.onWrite(self._content)
   1121         end
   1122         return true
   1123     end
   1124 
   1125     function handle:flush()
   1126         -- Flush current content via onWrite
   1127         if self._mode ~= "r" and self._pseudoFile.onWrite then
   1128             self._pseudoFile.onWrite(self._content)
   1129         end
   1130         return true
   1131     end
   1132 
   1133     function handle:lines()
   1134         return function()
   1135             return self:read("*l")
   1136         end
   1137     end
   1138 
   1139     return handle
   1140 end
   1141 
   1142 -- Open file (returns file handle-like table)
   1143 function SafeFS:open(path, mode)
   1144     mode = mode or "r"
   1145 
   1146     local resolvedPath, err = self:resolvePath(path)
   1147     if not resolvedPath then
   1148         return nil, err
   1149     end
   1150 
   1151     -- Protect root directory for write modes
   1152     if (mode == "w" or mode == "a") and isDirectlyInRoot(resolvedPath) then
   1153         return nil, "Cannot create files in root directory"
   1154     end
   1155 
   1156     -- Check if this is a pseudo file
   1157     local parts = splitPath(resolvedPath)
   1158     local filename = parts[#parts]
   1159     table.remove(parts)
   1160     local parentPath = joinPath(parts)
   1161 
   1162     local parentNode, parentErr = findInSafeFS(self, parentPath)
   1163     if parentNode and parentNode.files then
   1164         for _, file in ipairs(parentNode.files) do
   1165             if file.name == filename and file.isPseudo then
   1166                 -- Found a pseudo file
   1167                 if file.onOpen then
   1168                     -- Custom onOpen handler
   1169                     return file.onOpen(resolvedPath, mode)
   1170                 else
   1171                     -- Default pseudo file handle
   1172                     return createDefaultPseudoHandle(file, resolvedPath, mode)
   1173                 end
   1174             end
   1175         end
   1176     end
   1177 
   1178     -- Regular file handling
   1179     if mode == "r" then
   1180         -- Read mode
   1181         local content, err = self:read(resolvedPath)
   1182         if not content then
   1183             return nil, err
   1184         end
   1185 
   1186         local handle = {
   1187             _content = content,
   1188             _pos = 1,
   1189             _path = resolvedPath,
   1190             _safeFS = self
   1191         }
   1192 
   1193         function handle:read(format)
   1194             format = format or "*l"
   1195 
   1196             if format == "*a" or format == "*all" then
   1197                 local result = self._content:sub(self._pos)
   1198                 self._pos = #self._content + 1
   1199                 return result
   1200             elseif format == "*l" or format == "*line" then
   1201                 local start = self._pos
   1202                 local lineEnd = self._content:find("\n", start, true)
   1203                 if lineEnd then
   1204                     local line = self._content:sub(start, lineEnd - 1)
   1205                     self._pos = lineEnd + 1
   1206                     return line
   1207                 else
   1208                     if self._pos <= #self._content then
   1209                         local line = self._content:sub(start)
   1210                         self._pos = #self._content + 1
   1211                         return line
   1212                     else
   1213                         return nil
   1214                     end
   1215                 end
   1216             elseif type(format) == "number" then
   1217                 local result = self._content:sub(self._pos, self._pos + format - 1)
   1218                 self._pos = self._pos + format
   1219                 return result
   1220             end
   1221         end
   1222 
   1223         function handle:seek(whence, offset)
   1224             whence = whence or "cur"
   1225             offset = offset or 0
   1226 
   1227             local newPos
   1228             if whence == "set" then
   1229                 newPos = offset + 1
   1230             elseif whence == "cur" then
   1231                 newPos = self._pos + offset
   1232             elseif whence == "end" then
   1233                 newPos = #self._content + offset + 1
   1234             else
   1235                 return nil, "Invalid whence"
   1236             end
   1237 
   1238             if newPos < 1 then newPos = 1 end
   1239             self._pos = newPos
   1240             return self._pos - 1
   1241         end
   1242 
   1243         function handle:close()
   1244             -- Nothing to do for read mode
   1245         end
   1246 
   1247         return handle
   1248 
   1249     elseif mode == "w" or mode == "a" then
   1250         -- Write or append mode
   1251         local existingContent = ""
   1252         if mode == "a" then
   1253             existingContent = self:read(resolvedPath) or ""
   1254         end
   1255 
   1256         local handle = {
   1257             _content = existingContent,
   1258             _pos = #existingContent + 1,
   1259             _path = resolvedPath,
   1260             _safeFS = self
   1261         }
   1262 
   1263         function handle:write(data)
   1264             self._content = self._content .. tostring(data)
   1265             self._pos = #self._content + 1
   1266             return true
   1267         end
   1268 
   1269         function handle:seek(whence, offset)
   1270             whence = whence or "cur"
   1271             offset = offset or 0
   1272 
   1273             local newPos
   1274             if whence == "set" then
   1275                 newPos = offset + 1
   1276             elseif whence == "cur" then
   1277                 newPos = self._pos + offset
   1278             elseif whence == "end" then
   1279                 newPos = #self._content + offset + 1
   1280             else
   1281                 return nil, "Invalid whence"
   1282             end
   1283 
   1284             if newPos < 1 then newPos = 1 end
   1285             self._pos = newPos
   1286             return self._pos - 1
   1287         end
   1288 
   1289         function handle:close()
   1290             self._safeFS:write(self._path, self._content)
   1291         end
   1292 
   1293         return handle
   1294     else
   1295         return nil, "Invalid mode: " .. mode
   1296     end
   1297 end
   1298 
   1299 -- Delete file or directory
   1300 function SafeFS:delete(path)
   1301     local resolvedPath, err = self:resolvePath(path)
   1302     if not resolvedPath then
   1303         return false, err
   1304     end
   1305 
   1306     -- Find parent directory
   1307     local parts = splitPath(resolvedPath)
   1308     local targetName = parts[#parts]
   1309     table.remove(parts)
   1310     local parentPath = joinPath(parts)
   1311 
   1312     local parentNode, err = findInSafeFS(self, parentPath)
   1313     if not parentNode then
   1314         return false, err or "Parent directory not found"
   1315     end
   1316 
   1317     if parentNode.type ~= "directory" then
   1318         return false, "Parent is not a directory"
   1319     end
   1320 
   1321     -- Remove from directories
   1322     if parentNode.dirs then
   1323         for i, dir in ipairs(parentNode.dirs) do
   1324             if dir.name == targetName then
   1325                 table.remove(parentNode.dirs, i)
   1326                 return true
   1327             end
   1328         end
   1329     end
   1330 
   1331     -- Remove from files
   1332     if parentNode.files then
   1333         for i, file in ipairs(parentNode.files) do
   1334             if file.name == targetName then
   1335                 -- Call onDelete callback for pseudo files
   1336                 if file.isPseudo and file.onDelete then
   1337                     local success, err = pcall(file.onDelete, file)
   1338                     if not success then
   1339                         return false, "onDelete callback error: " .. tostring(err)
   1340                     end
   1341                 end
   1342 
   1343                 table.remove(parentNode.files, i)
   1344                 return true
   1345             end
   1346         end
   1347     end
   1348 
   1349     return false, "Not found"
   1350 end
   1351 
   1352 -- List directories in path
   1353 function SafeFS:dirs(path)
   1354     local resolvedPath, err = self:resolvePath(path)
   1355     if not resolvedPath then
   1356         return nil, err
   1357     end
   1358 
   1359     local node, err = findInSafeFS(self, resolvedPath)
   1360     if not node then
   1361         return nil, err or "Path not found"
   1362     end
   1363 
   1364     if node.type ~= "directory" then
   1365         return nil, "Not a directory"
   1366     end
   1367 
   1368     -- Refresh pseudo directory contents from callbacks
   1369     if node.isPseudo and node.refresh then
   1370         node:refresh()
   1371     end
   1372 
   1373     if osprint then
   1374         osprint("[SafeFS.dirs] Node found for path: " .. resolvedPath .. "\n")
   1375         osprint("[SafeFS.dirs] Node keys:\n")
   1376         for k,v in pairs(node) do
   1377             osprint("  " .. k .. " = " .. tostring(v) .. "\n")
   1378         end
   1379         if node.dirs then
   1380             osprint("[SafeFS.dirs] node.dirs has " .. #node.dirs .. " entries\n")
   1381             for i, dir in ipairs(node.dirs) do
   1382                 osprint("  [" .. i .. "] name=" .. tostring(dir.name) .. " type=" .. tostring(dir.type) .. "\n")
   1383             end
   1384         else
   1385             osprint("[SafeFS.dirs] node.dirs is nil\n")
   1386         end
   1387     end
   1388 
   1389     local result = {}
   1390 
   1391     -- If node doesn't have dirs array, use _CRamdiskList to get them
   1392     if not node.dirs and _CRamdiskList then
   1393         if osprint then
   1394             osprint("[SafeFS.dirs] node.dirs is nil, using _CRamdiskList(" .. resolvedPath .. ")\n")
   1395         end
   1396 
   1397         local items = _CRamdiskList(resolvedPath)
   1398         if items then
   1399             for _, item in ipairs(items) do
   1400                 if item.type == "directory" or item.type == "dir" then
   1401                     -- Strip leading slash from name if present
   1402                     local itemName = item.name
   1403                     if itemName:sub(1, 1) == "/" then
   1404                         itemName = itemName:sub(2)
   1405                     end
   1406                     table.insert(result, itemName)
   1407                 end
   1408             end
   1409         end
   1410     elseif node.dirs then
   1411         for _, dir in ipairs(node.dirs) do
   1412             -- Strip leading slash from name if present
   1413             local dirName = dir.name
   1414             if dirName:sub(1, 1) == "/" then
   1415                 dirName = dirName:sub(2)
   1416             end
   1417             table.insert(result, dirName)
   1418         end
   1419     end
   1420 
   1421     return result
   1422 end
   1423 
   1424 -- List files in path
   1425 function SafeFS:files(path)
   1426     local resolvedPath, err = self:resolvePath(path)
   1427     if not resolvedPath then
   1428         return nil, err
   1429     end
   1430 
   1431     if osprint then
   1432         osprint("[SafeFS.files] Looking for files in path: " .. resolvedPath .. "\n")
   1433     end
   1434 
   1435     local node, err = findInSafeFS(self, resolvedPath)
   1436     if not node then
   1437         if osprint then
   1438             osprint("[SafeFS.files] Node not found: " .. tostring(err) .. "\n")
   1439         end
   1440         return nil, err or "Path not found"
   1441     end
   1442 
   1443     if node.type ~= "directory" then
   1444         return nil, "Not a directory"
   1445     end
   1446 
   1447     -- Refresh pseudo directory contents from callbacks
   1448     if node.isPseudo and node.refresh then
   1449         node:refresh()
   1450     end
   1451 
   1452     if osprint then
   1453         osprint("[SafeFS.files] Node found, node.files=" .. tostring(node.files) .. "\n")
   1454         osprint("[SafeFS.files] _CRamdiskList available: " .. tostring(_CRamdiskList ~= nil) .. "\n")
   1455     end
   1456 
   1457     local result = {}
   1458 
   1459     -- If node doesn't have files array, use _CRamdiskList to get them
   1460     if not node.files and _CRamdiskList then
   1461         if osprint then
   1462             osprint("[SafeFS.files] Using _CRamdiskList fallback for: " .. resolvedPath .. "\n")
   1463         end
   1464         local items = _CRamdiskList(resolvedPath)
   1465         if items then
   1466             if osprint then
   1467                 osprint("[SafeFS.files] _CRamdiskList returned " .. #items .. " items\n")
   1468             end
   1469             for _, item in ipairs(items) do
   1470                 if osprint then
   1471                     osprint("[SafeFS.files]   Item: name=" .. tostring(item.name) .. " type=" .. tostring(item.type) .. "\n")
   1472                 end
   1473                 if item.type == "file" then
   1474                     -- Strip leading slash from name if present
   1475                     local itemName = item.name
   1476                     if itemName:sub(1, 1) == "/" then
   1477                         itemName = itemName:sub(2)
   1478                     end
   1479                     table.insert(result, itemName)
   1480                     if osprint then
   1481                         osprint("[SafeFS.files]   Added file: " .. itemName .. "\n")
   1482                     end
   1483                 end
   1484             end
   1485         else
   1486             if osprint then
   1487                 osprint("[SafeFS.files] _CRamdiskList returned nil\n")
   1488             end
   1489         end
   1490     elseif node.files then
   1491         if osprint then
   1492             osprint("[SafeFS.files] Using node.files array (" .. #node.files .. " files)\n")
   1493         end
   1494         for _, file in ipairs(node.files) do
   1495             -- Strip leading slash from name if present
   1496             local fileName = file.name
   1497             if fileName:sub(1, 1) == "/" then
   1498                 fileName = fileName:sub(2)
   1499             end
   1500             table.insert(result, fileName)
   1501         end
   1502     end
   1503 
   1504     if osprint then
   1505         osprint("[SafeFS.files] Returning " .. #result .. " files\n")
   1506     end
   1507 
   1508     return result
   1509 end
   1510 
   1511 -- Check if path exists
   1512 function SafeFS:exists(path)
   1513     local resolvedPath, err = self:resolvePath(path)
   1514     if not resolvedPath then
   1515         return false
   1516     end
   1517 
   1518     local node = findInSafeFS(self, resolvedPath)
   1519     return node ~= nil
   1520 end
   1521 
   1522 -- Get path type ("file", "directory", or nil)
   1523 function SafeFS:getType(path)
   1524     local resolvedPath, err = self:resolvePath(path)
   1525     if not resolvedPath then
   1526         return nil, err
   1527     end
   1528 
   1529     local node = findInSafeFS(self, resolvedPath)
   1530     if not node then
   1531         return nil
   1532     end
   1533 
   1534     return node.type
   1535 end
   1536 
   1537 -- Create directory
   1538 function SafeFS:mkdir(path)
   1539     local resolvedPath, err = self:resolvePath(path)
   1540     if not resolvedPath then
   1541         return false, err
   1542     end
   1543 
   1544     -- Protect root directory - cannot create directories directly in root
   1545     if isDirectlyInRoot(resolvedPath) then
   1546         return false, "Cannot create directories in root directory"
   1547     end
   1548 
   1549     local node, err = ensureDirectory(self, resolvedPath)
   1550     if not node then
   1551         return false, err
   1552     end
   1553 
   1554     return true
   1555 end
   1556 
   1557 -- Get the filename (last component) from a path
   1558 function SafeFS:fileName(path)
   1559     local resolvedPath, err = self:resolvePath(path)
   1560     if not resolvedPath then
   1561         return nil, err
   1562     end
   1563 
   1564     -- Handle root case
   1565     if resolvedPath == "/" then
   1566         return "/"
   1567     end
   1568 
   1569     -- Split path and get last component
   1570     local parts = splitPath(resolvedPath)
   1571     if #parts == 0 then
   1572         return "/"
   1573     end
   1574 
   1575     return parts[#parts]
   1576 end
   1577 
   1578 -- Get the parent directory path
   1579 function SafeFS:parentDir(path)
   1580     local resolvedPath, err = self:resolvePath(path)
   1581     if not resolvedPath then
   1582         return nil, err
   1583     end
   1584 
   1585     -- Root has no parent
   1586     if resolvedPath == "/" then
   1587         return nil, "Root has no parent"
   1588     end
   1589 
   1590     -- Split path and remove last component
   1591     local parts = splitPath(resolvedPath)
   1592     if #parts <= 1 then
   1593         return "/"
   1594     end
   1595 
   1596     table.remove(parts)
   1597     return joinPath(parts)
   1598 end
   1599 
   1600 -- Join multiple path segments without validation
   1601 -- Example: join("/folder", "subfolder/test.txt") -> "/folder/subfolder/test.txt"
   1602 -- Example: join("/apps", "com.luajit.calculator", "/data/", "/test.txt") -> "/apps/com.luajit.calculator/data/test.txt"
   1603 function SafeFS:join(...)
   1604     local segments = {...}
   1605     if #segments == 0 then
   1606         return ""
   1607     end
   1608 
   1609     local result = ""
   1610 
   1611     for i, segment in ipairs(segments) do
   1612         if segment and segment ~= "" then
   1613             -- Remove leading slashes from all segments except the first
   1614             if i > 1 and segment:sub(1, 1) == "/" then
   1615                 segment = segment:sub(2)
   1616             end
   1617 
   1618             -- Remove trailing slashes from all segments
   1619             while segment:sub(-1) == "/" and #segment > 1 do
   1620                 segment = segment:sub(1, -2)
   1621             end
   1622 
   1623             -- Add segment to result
   1624             if result == "" then
   1625                 result = segment
   1626             elseif result == "/" then
   1627                 result = "/" .. segment
   1628             else
   1629                 result = result .. "/" .. segment
   1630             end
   1631         end
   1632     end
   1633 
   1634     return result
   1635 end
   1636 
   1637 -- Get relative path from base to target without validation
   1638 -- Example: relativeTo("/Program Files", "/Program Files/Microsoft") -> "Microsoft"
   1639 -- Example: relativeTo("/apps/test", "/apps/test/data/file.txt") -> "data/file.txt"
   1640 function SafeFS:relativeTo(base, target)
   1641     -- Normalize paths by removing trailing slashes
   1642     if base:sub(-1) == "/" and #base > 1 then
   1643         base = base:sub(1, -2)
   1644     end
   1645     if target:sub(-1) == "/" and #target > 1 then
   1646         target = target:sub(1, -2)
   1647     end
   1648 
   1649     -- Check if target starts with base
   1650     if target:sub(1, #base) == base then
   1651         -- If they're exactly equal, return empty string
   1652         if #target == #base then
   1653             return ""
   1654         end
   1655 
   1656         -- Check that the next character is a slash (to avoid partial matches)
   1657         if target:sub(#base + 1, #base + 1) == "/" then
   1658             return target:sub(#base + 2)  -- Skip the slash
   1659         end
   1660     end
   1661 
   1662     -- If target doesn't start with base, return the full target
   1663     return target
   1664 end
   1665 
   1666 -- Copy a file or directory from source to destination
   1667 function SafeFS:copy(source, destination)
   1668     local sourceResolved, err = self:resolvePath(source)
   1669     if not sourceResolved then
   1670         return nil, err
   1671     end
   1672 
   1673     local destResolved, err = self:resolvePath(destination)
   1674     if not destResolved then
   1675         return nil, err
   1676     end
   1677 
   1678     -- Protect root directory - cannot copy to root
   1679     if isDirectlyInRoot(destResolved) then
   1680         return nil, "Cannot copy to root directory"
   1681     end
   1682 
   1683     -- Check if source exists
   1684     local sourceNode = self.root:traverse(sourceResolved)
   1685     if not sourceNode then
   1686         return nil, "Source does not exist: " .. source
   1687     end
   1688 
   1689     -- Check source type
   1690     local sourceType = sourceNode.type
   1691     if sourceType ~= "file" and sourceType ~= "dir" then
   1692         return nil, "Invalid source type"
   1693     end
   1694 
   1695     -- Helper function to recursively copy directory
   1696     local function copyDir(srcNode, destPath)
   1697         -- Create destination directory
   1698         local success, err = self:mkdir(destPath)
   1699         if not success then
   1700             return nil, err
   1701         end
   1702 
   1703         -- Copy all files in the directory
   1704         for _, file in ipairs(srcNode.files) do
   1705             local srcFilePath = self:join(sourceResolved, file.name)
   1706             local destFilePath = self:join(destPath, file.name)
   1707             local content, err = self:read(srcFilePath)
   1708             if not content then
   1709                 return nil, "Failed to read " .. srcFilePath .. ": " .. (err or "unknown error")
   1710             end
   1711             local success, err = self:write(destFilePath, content)
   1712             if not success then
   1713                 return nil, "Failed to write " .. destFilePath .. ": " .. (err or "unknown error")
   1714             end
   1715         end
   1716 
   1717         -- Recursively copy subdirectories
   1718         for _, dir in ipairs(srcNode.dirs) do
   1719             local srcDirPath = self:join(sourceResolved, dir.name)
   1720             local destDirPath = self:join(destPath, dir.name)
   1721             local success, err = copyDir(dir, destDirPath)
   1722             if not success then
   1723                 return nil, err
   1724             end
   1725         end
   1726 
   1727         return true
   1728     end
   1729 
   1730     -- Copy based on type
   1731     if sourceType == "file" then
   1732         -- Copy file
   1733         local content, err = self:read(sourceResolved)
   1734         if not content then
   1735             return nil, "Failed to read source: " .. (err or "unknown error")
   1736         end
   1737         return self:write(destResolved, content)
   1738     else
   1739         -- Copy directory
   1740         return copyDir(sourceNode, destResolved)
   1741     end
   1742 end
   1743 
   1744 -- Move a file or directory from source to destination
   1745 function SafeFS:move(source, destination)
   1746     local destResolved, err = self:resolvePath(destination)
   1747     if not destResolved then
   1748         return nil, err
   1749     end
   1750 
   1751     -- Protect root directory - cannot move to root
   1752     if isDirectlyInRoot(destResolved) then
   1753         return nil, "Cannot move to root directory"
   1754     end
   1755 
   1756     -- Copy first
   1757     local success, err = self:copy(source, destination)
   1758     if not success then
   1759         return nil, "Failed to copy: " .. (err or "unknown error")
   1760     end
   1761 
   1762     -- Delete source
   1763     local success, err = self:delete(source)
   1764     if not success then
   1765         return nil, "Failed to delete source: " .. (err or "unknown error")
   1766     end
   1767 
   1768     return true
   1769 end
   1770 
   1771 -- Create a pseudo file that uses callbacks instead of content
   1772 -- The file node will call onRead(args) to get content and onWrite(data) to receive writes
   1773 function SafeFS:createPseudoFile(path)
   1774     local resolvedPath, err = self:resolvePath(path)
   1775     if not resolvedPath then
   1776         return nil, err
   1777     end
   1778 
   1779     -- Split path into directory and filename
   1780     local parts = splitPath(resolvedPath)
   1781     local filename = parts[#parts]
   1782     table.remove(parts)
   1783     local dirPath = joinPath(parts)
   1784 
   1785     -- Ensure parent directory exists
   1786     local parentDir, err = ensureDirectory(self, dirPath)
   1787     if not parentDir then
   1788         return nil, err or "Parent directory not found"
   1789     end
   1790 
   1791     -- Check if file already exists
   1792     if parentDir.files then
   1793         for _, file in ipairs(parentDir.files) do
   1794             if file.name == filename then
   1795                 return nil, "File already exists"
   1796             end
   1797 
   1798             -- Check if there's any file that looks like it has arguments matching this pseudo file
   1799             -- e.g., creating "/sys/date" when "/sys/date file.txt" exists
   1800             if not file.isPseudo then
   1801                 local spacePos = file.name:find(" ")
   1802                 if spacePos then
   1803                     local baseName = file.name:sub(1, spacePos - 1)
   1804                     if baseName == filename then
   1805                         return nil, "Cannot create pseudo file: file with arguments pattern exists"
   1806                     end
   1807                 end
   1808             end
   1809         end
   1810     end
   1811 
   1812     -- Create the pseudo file node
   1813     if not parentDir.files then
   1814         parentDir.files = {}
   1815     end
   1816 
   1817     local pseudoFile = {
   1818         name = filename,
   1819         type = "file",
   1820         parent = parentDir,
   1821         isPseudo = true,
   1822         onRead = nil,    -- User sets this callback: function() return content end
   1823         onWrite = nil,   -- User sets this callback: function(data) end
   1824         onOpen = nil,    -- User sets this callback: function(path, mode) return handle end
   1825         onDelete = nil   -- User sets this callback: function() end
   1826     }
   1827 
   1828     -- Add delete method to remove itself from parent
   1829     function pseudoFile:delete()
   1830         -- Call onDelete callback if set
   1831         if self.onDelete then
   1832             local success, err = pcall(self.onDelete, self)
   1833             if not success then
   1834                 return false, "onDelete callback error: " .. tostring(err)
   1835             end
   1836         end
   1837 
   1838         -- Remove from parent's files list
   1839         if self.parent and self.parent.files then
   1840             for i, file in ipairs(self.parent.files) do
   1841                 if file == self then
   1842                     table.remove(self.parent.files, i)
   1843                     return true
   1844                 end
   1845             end
   1846         end
   1847 
   1848         return false, "Could not find file in parent directory"
   1849     end
   1850 
   1851     table.insert(parentDir.files, pseudoFile)
   1852 
   1853     return pseudoFile
   1854 end
   1855 
   1856 -- Create a pseudo directory that uses callbacks to list contents
   1857 -- The directory node will call onFiles() and onDirs() to get contents
   1858 -- @param path: Path to create the pseudo directory at
   1859 -- @return pseudoDir object or nil, error
   1860 function SafeFS:createPseudoDir(path)
   1861     local resolvedPath, err = self:resolvePath(path)
   1862     if not resolvedPath then
   1863         return nil, err
   1864     end
   1865 
   1866     -- Split path into directory and name
   1867     local parts = splitPath(resolvedPath)
   1868     local dirname = parts[#parts]
   1869     table.remove(parts)
   1870     local parentPath = joinPath(parts)
   1871 
   1872     -- Ensure parent directory exists
   1873     local parentDir, err = ensureDirectory(self, parentPath)
   1874     if not parentDir then
   1875         return nil, err or "Parent directory not found"
   1876     end
   1877 
   1878     -- Check if directory already exists
   1879     if parentDir.dirs then
   1880         for _, dir in ipairs(parentDir.dirs) do
   1881             if dir.name == dirname then
   1882                 return nil, "Directory already exists"
   1883             end
   1884         end
   1885     end
   1886 
   1887     -- Create the pseudo directory node
   1888     if not parentDir.dirs then
   1889         parentDir.dirs = {}
   1890     end
   1891 
   1892     local pseudoDir = {
   1893         name = dirname,
   1894         type = "directory",
   1895         parent = parentDir,
   1896         isPseudo = true,
   1897         onFiles = nil,    -- User sets this: function() return {"file1.txt", "file2.txt"} end
   1898         onDirs = nil,     -- User sets this: function() return {"subdir1", "subdir2"} end
   1899         onRead = nil,     -- User sets this: function(filename) return content end (for reading files in this dir)
   1900         onWrite = nil,    -- User sets this: function(filename, data) end (for writing files in this dir)
   1901         onDelete = nil,   -- User sets this: function() end
   1902         onTraverse = nil, -- User sets this: function(relativePath) return node end (called when traversing into this dir)
   1903         -- Virtual files/dirs caches (populated from callbacks)
   1904         files = {},
   1905         dirs = {}
   1906     }
   1907 
   1908     -- Default onTraverse returns self
   1909     pseudoDir.onTraverse = function(relativePath)
   1910         return pseudoDir
   1911     end
   1912 
   1913     -- Method to refresh the virtual contents from callbacks
   1914     function pseudoDir:refresh()
   1915         self.files = {}
   1916         self.dirs = {}
   1917 
   1918         -- Get files from callback
   1919         if self.onFiles then
   1920             local fileNames = self.onFiles()
   1921             if fileNames and type(fileNames) == "table" then
   1922                 for _, name in ipairs(fileNames) do
   1923                     -- Create a virtual file entry that delegates to parent's onRead/onWrite
   1924                     local virtualFile = {
   1925                         name = name,
   1926                         type = "file",
   1927                         parent = self,
   1928                         isPseudo = true,
   1929                         _parentPseudoDir = self
   1930                     }
   1931                     -- Set up onRead to delegate to parent
   1932                     virtualFile.onRead = function()
   1933                         if self.onRead then
   1934                             return self.onRead(name)
   1935                         end
   1936                         return nil
   1937                     end
   1938                     -- Set up onWrite to delegate to parent
   1939                     virtualFile.onWrite = function(data)
   1940                         if self.onWrite then
   1941                             self.onWrite(name, data)
   1942                         end
   1943                     end
   1944                     table.insert(self.files, virtualFile)
   1945                 end
   1946             end
   1947         end
   1948 
   1949         -- Get directories from callback
   1950         if self.onDirs then
   1951             local dirNames = self.onDirs()
   1952             if dirNames and type(dirNames) == "table" then
   1953                 for _, name in ipairs(dirNames) do
   1954                     local virtualDir = {
   1955                         name = name,
   1956                         type = "directory",
   1957                         parent = self,
   1958                         isPseudo = true,
   1959                         files = {},
   1960                         dirs = {}
   1961                     }
   1962                     table.insert(self.dirs, virtualDir)
   1963                 end
   1964             end
   1965         end
   1966     end
   1967 
   1968     -- Add delete method
   1969     function pseudoDir:delete()
   1970         if self.onDelete then
   1971             local success, err = pcall(self.onDelete, self)
   1972             if not success then
   1973                 return false, "onDelete callback error: " .. tostring(err)
   1974             end
   1975         end
   1976 
   1977         -- Remove from parent's dirs list
   1978         if self.parent and self.parent.dirs then
   1979             for i, dir in ipairs(self.parent.dirs) do
   1980                 if dir == self then
   1981                     table.remove(self.parent.dirs, i)
   1982                     return true
   1983                 end
   1984             end
   1985         end
   1986 
   1987         return false, "Could not find directory in parent"
   1988     end
   1989 
   1990     table.insert(parentDir.dirs, pseudoDir)
   1991 
   1992     return pseudoDir
   1993 end
   1994 
   1995 -- Register a file handler for an extension
   1996 -- extension: file extension without dot (e.g., "txt", "lua")
   1997 -- functionName: name of the function to call on the app (e.g., "openFile")
   1998 -- The handler will be associated with the calling app's ID
   1999 function SafeFS:addFileHandler(extension, functionName)
   2000     if not extension or type(extension) ~= "string" then
   2001         return false, "Extension must be a string"
   2002     end
   2003 
   2004     if not functionName or type(functionName) ~= "string" then
   2005         return false, "Function name must be a string"
   2006     end
   2007 
   2008     -- Normalize extension (remove leading dot if present, lowercase)
   2009     extension = extension:gsub("^%.", ""):lower()
   2010 
   2011     -- Get the app ID from the SafeFS instance
   2012     -- The app ID should be stored when the SafeFS instance is created
   2013     local appId = self._appId
   2014     if not appId then
   2015         return false, "No app ID associated with this SafeFS instance"
   2016     end
   2017 
   2018     -- Register the handler in the internal table
   2019     _fileHandlers[extension] = {
   2020         appId = appId,
   2021         functionName = functionName
   2022     }
   2023 
   2024     if osprint then
   2025         osprint("[SafeFS] Registered file handler: ." .. extension .. " -> " .. appId .. ":" .. functionName .. "\n")
   2026     end
   2027 
   2028     return true
   2029 end
   2030 
   2031 -- Remove a file handler for an extension
   2032 -- Only the app that registered the handler can remove it
   2033 function SafeFS:removeFileHandler(extension)
   2034     if not extension or type(extension) ~= "string" then
   2035         return false, "Extension must be a string"
   2036     end
   2037 
   2038     -- Normalize extension
   2039     extension = extension:gsub("^%.", ""):lower()
   2040 
   2041     -- Check if handler exists
   2042     local handler = _fileHandlers[extension]
   2043     if not handler then
   2044         return false, "No handler registered for extension: " .. extension
   2045     end
   2046 
   2047     -- Only the app that registered the handler can remove it
   2048     local appId = self._appId
   2049     if handler.appId ~= appId then
   2050         return false, "Cannot remove handler registered by another app"
   2051     end
   2052 
   2053     -- Remove the handler
   2054     _fileHandlers[extension] = nil
   2055 
   2056     if osprint then
   2057         osprint("[SafeFS] Removed file handler for: ." .. extension .. "\n")
   2058     end
   2059 
   2060     return true
   2061 end
   2062 
   2063 -- Module-level function to get a file handler (used by sys.openFile)
   2064 -- This is not exposed on SafeFS instances, only at the module level
   2065 function SafeFS.getFileHandler(extension)
   2066     if not extension or type(extension) ~= "string" then
   2067         return nil
   2068     end
   2069 
   2070     -- Normalize extension
   2071     extension = extension:gsub("^%.", ""):lower()
   2072 
   2073     return _fileHandlers[extension]
   2074 end
   2075 
   2076 -- Module-level function to get all registered file handlers
   2077 function SafeFS.getAllFileHandlers()
   2078     -- Return a copy to prevent modification
   2079     local copy = {}
   2080     for ext, handler in pairs(_fileHandlers) do
   2081         copy[ext] = {
   2082             appId = handler.appId,
   2083             functionName = handler.functionName
   2084         }
   2085     end
   2086     return copy
   2087 end
   2088 
   2089 -- Create a PseudoDir for a DiskFS drive that handles all file operations via the C diskfs API
   2090 -- @param mountPath: The mount path (e.g., "/mnt/hd0")
   2091 -- @param bus: ATA bus number
   2092 -- @param drive: ATA drive number
   2093 -- @return pseudoDir object or nil, error
   2094 function SafeFS:createDiskFSMount(mountPath, bus, drive)
   2095     -- Check if diskfs is available
   2096     if not diskfs then
   2097         return nil, "diskfs module not available"
   2098     end
   2099 
   2100     -- Resolve the mount path
   2101     local resolvedPath, err = self:resolvePath(mountPath)
   2102     if not resolvedPath then
   2103         return nil, err
   2104     end
   2105 
   2106     -- Split path into parent and mount name
   2107     local parts = splitPath(resolvedPath)
   2108     local mountName = parts[#parts]
   2109     table.remove(parts)
   2110     local parentPath = joinPath(parts)
   2111 
   2112     -- Ensure parent directory exists (e.g., /mnt)
   2113     local parentDir, err = ensureDirectory(self, parentPath)
   2114     if not parentDir then
   2115         return nil, err or "Parent directory not found"
   2116     end
   2117 
   2118     -- Check if directory already exists
   2119     if parentDir.dirs then
   2120         for _, dir in ipairs(parentDir.dirs) do
   2121             if dir.name == mountName then
   2122                 return nil, "Mount point already exists"
   2123             end
   2124         end
   2125     end
   2126 
   2127     -- Create the pseudo directory for the disk mount
   2128     if not parentDir.dirs then
   2129         parentDir.dirs = {}
   2130     end
   2131 
   2132     local diskMount = {
   2133         name = mountName,
   2134         type = "directory",
   2135         parent = parentDir,
   2136         isPseudo = true,
   2137         _bus = bus,
   2138         _drive = drive,
   2139         _mountPath = resolvedPath,
   2140         files = {},
   2141         dirs = {}
   2142     }
   2143 
   2144     -- Helper to build full diskfs path
   2145     local function buildDiskPath(relativePath)
   2146         if not relativePath or relativePath == "" then
   2147             return resolvedPath
   2148         end
   2149         return resolvedPath .. "/" .. relativePath
   2150     end
   2151 
   2152     -- onTraverse handles path traversal into the disk filesystem
   2153     -- Returns a virtual node representing the file/dir at the given path
   2154     diskMount.onTraverse = function(relativePath)
   2155         local fullPath = buildDiskPath(relativePath)
   2156 
   2157         -- Check if path exists on disk
   2158         if not diskfs.exists(fullPath) then
   2159             return nil
   2160         end
   2161 
   2162         -- Create appropriate virtual node
   2163         if diskfs.isdir(fullPath) then
   2164             -- Return a virtual directory node
   2165             local virtualDir = {
   2166                 name = relativePath:match("([^/]+)$") or mountName,
   2167                 type = "directory",
   2168                 isPseudo = true,
   2169                 _diskPath = fullPath,
   2170                 _bus = bus,
   2171                 _drive = drive,
   2172                 files = {},
   2173                 dirs = {}
   2174             }
   2175 
   2176             -- Populate files and dirs from diskfs.list
   2177             local items = diskfs.list(fullPath)
   2178             if items then
   2179                 for _, item in ipairs(items) do
   2180                     if item.type == "dir" then
   2181                         table.insert(virtualDir.dirs, {
   2182                             name = item.name,
   2183                             type = "directory",
   2184                             isPseudo = true,
   2185                             _diskPath = fullPath .. "/" .. item.name
   2186                         })
   2187                     else
   2188                         -- Create virtual file with diskfs callbacks
   2189                         local virtualFile = {
   2190                             name = item.name,
   2191                             type = "file",
   2192                             isPseudo = true,
   2193                             _diskPath = fullPath .. "/" .. item.name
   2194                         }
   2195                         virtualFile.onRead = function()
   2196                             local handle = diskfs.open(virtualFile._diskPath, "r")
   2197                             if handle then
   2198                                 local content = diskfs.read(handle)
   2199                                 diskfs.close(handle)
   2200                                 return content
   2201                             end
   2202                             return nil
   2203                         end
   2204                         virtualFile.onWrite = function(data)
   2205                             local handle = diskfs.open(virtualFile._diskPath, "w")
   2206                             if handle then
   2207                                 diskfs.write(handle, data)
   2208                                 diskfs.close(handle)
   2209                             end
   2210                         end
   2211                         table.insert(virtualDir.files, virtualFile)
   2212                     end
   2213                 end
   2214             end
   2215 
   2216             -- Add onTraverse for subdirectory traversal
   2217             virtualDir.onTraverse = function(subPath)
   2218                 if not subPath or subPath == "" then
   2219                     return virtualDir
   2220                 end
   2221                 local newFullPath = fullPath .. "/" .. subPath
   2222                 if diskfs.exists(newFullPath) then
   2223                     -- Recursively create node for subpath
   2224                     return diskMount.onTraverse(relativePath .. "/" .. subPath)
   2225                 end
   2226                 return nil
   2227             end
   2228 
   2229             return virtualDir
   2230         else
   2231             -- Return a virtual file node
   2232             local virtualFile = {
   2233                 name = relativePath:match("([^/]+)$") or relativePath,
   2234                 type = "file",
   2235                 isPseudo = true,
   2236                 _diskPath = fullPath
   2237             }
   2238             virtualFile.onRead = function()
   2239                 local handle = diskfs.open(fullPath, "r")
   2240                 if handle then
   2241                     local content = diskfs.read(handle)
   2242                     diskfs.close(handle)
   2243                     return content
   2244                 end
   2245                 return nil
   2246             end
   2247             virtualFile.onWrite = function(data)
   2248                 local handle = diskfs.open(fullPath, "w")
   2249                 if handle then
   2250                     diskfs.write(handle, data)
   2251                     diskfs.close(handle)
   2252                 end
   2253             end
   2254             return virtualFile
   2255         end
   2256     end
   2257 
   2258     -- onFiles returns list of files in root of disk
   2259     diskMount.onFiles = function()
   2260         local items = diskfs.list(resolvedPath)
   2261         local files = {}
   2262         if items then
   2263             for _, item in ipairs(items) do
   2264                 if item.type == "file" then
   2265                     table.insert(files, item.name)
   2266                 end
   2267             end
   2268         end
   2269         return files
   2270     end
   2271 
   2272     -- onDirs returns list of directories in root of disk
   2273     diskMount.onDirs = function()
   2274         local items = diskfs.list(resolvedPath)
   2275         local dirs = {}
   2276         if items then
   2277             for _, item in ipairs(items) do
   2278                 if item.type == "dir" then
   2279                     table.insert(dirs, item.name)
   2280                 end
   2281             end
   2282         end
   2283         return dirs
   2284     end
   2285 
   2286     -- onRead for files in the disk root
   2287     diskMount.onRead = function(filename)
   2288         local filePath = resolvedPath .. "/" .. filename
   2289         local handle = diskfs.open(filePath, "r")
   2290         if handle then
   2291             local content = diskfs.read(handle)
   2292             diskfs.close(handle)
   2293             return content
   2294         end
   2295         return nil
   2296     end
   2297 
   2298     -- onWrite for files in the disk root
   2299     diskMount.onWrite = function(filename, data)
   2300         local filePath = resolvedPath .. "/" .. filename
   2301         local handle = diskfs.open(filePath, "w")
   2302         if handle then
   2303             diskfs.write(handle, data)
   2304             diskfs.close(handle)
   2305         end
   2306     end
   2307 
   2308     -- refresh populates files/dirs arrays from callbacks
   2309     function diskMount:refresh()
   2310         self.files = {}
   2311         self.dirs = {}
   2312 
   2313         -- Get files from callback
   2314         local fileNames = self.onFiles()
   2315         if fileNames and type(fileNames) == "table" then
   2316             for _, name in ipairs(fileNames) do
   2317                 local virtualFile = {
   2318                     name = name,
   2319                     type = "file",
   2320                     parent = self,
   2321                     isPseudo = true,
   2322                     _diskPath = resolvedPath .. "/" .. name
   2323                 }
   2324                 virtualFile.onRead = function()
   2325                     return self.onRead(name)
   2326                 end
   2327                 virtualFile.onWrite = function(data)
   2328                     self.onWrite(name, data)
   2329                 end
   2330                 table.insert(self.files, virtualFile)
   2331             end
   2332         end
   2333 
   2334         -- Get directories from callback
   2335         local dirNames = self.onDirs()
   2336         if dirNames and type(dirNames) == "table" then
   2337             for _, name in ipairs(dirNames) do
   2338                 local virtualDir = {
   2339                     name = name,
   2340                     type = "directory",
   2341                     parent = self,
   2342                     isPseudo = true,
   2343                     _diskPath = resolvedPath .. "/" .. name,
   2344                     files = {},
   2345                     dirs = {}
   2346                 }
   2347                 table.insert(self.dirs, virtualDir)
   2348             end
   2349         end
   2350     end
   2351 
   2352     -- Add to parent's dirs
   2353     table.insert(parentDir.dirs, diskMount)
   2354 
   2355     return diskMount
   2356 end
   2357 
   2358 -- Mount all detected DiskFS drives at /mnt/hdX
   2359 -- This should be called after SafeFS is created with /mnt/* access
   2360 function SafeFS:mountAllDiskDrives()
   2361     if not diskfs then
   2362         return false, "diskfs module not available"
   2363     end
   2364 
   2365     local mounts = diskfs.getMounts()
   2366     if not mounts then
   2367         return false, "No drives detected"
   2368     end
   2369 
   2370     local mountCount = 0
   2371     for i, mount in ipairs(mounts) do
   2372         if mount.formatted then
   2373             local mountPath = "/mnt/hd" .. (i - 1)
   2374             local pseudoDir, err = self:createDiskFSMount(mountPath, mount.bus, mount.drive)
   2375             if pseudoDir then
   2376                 mountCount = mountCount + 1
   2377                 if osprint then
   2378                     osprint("[SafeFS] Mounted DiskFS drive at " .. mountPath .. " (" .. (mount.volume_name or "unnamed") .. ")\n")
   2379                 end
   2380             else
   2381                 if osprint then
   2382                     osprint("[SafeFS] Failed to mount " .. mountPath .. ": " .. tostring(err) .. "\n")
   2383                 end
   2384             end
   2385         end
   2386     end
   2387 
   2388     return mountCount > 0, "Mounted " .. mountCount .. " drive(s)"
   2389 end
   2390 
   2391 return SafeFS