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