luajitos

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

LAM.lua (34276B)


      1 -- LAM.lua - LuajitOS Application Manager
      2 -- Package and install applications in .lam format
      3 --
      4 -- LAM file format (v2 with optional signature):
      5 --   [4 bytes] Magic "LAM\0"
      6 --   [1 byte]  Has signature (0 = no, 1 = yes)
      7 --   If signed:
      8 --     [64 bytes] ED25519 signature (signs everything after this field)
      9 --     [2 bytes]  Developer name length
     10 --     [N bytes]  Developer name string
     11 --   [1 byte]  Null separator
     12 --   [4 bytes] Version (2)
     13 --   [4 bytes] Manifest length
     14 --   [N bytes] Manifest string (Lua code)
     15 --   [1 byte]  Null separator
     16 --   [4 bytes] Number of directories
     17 --   For each directory:
     18 --     [2 bytes] Path length
     19 --     [N bytes] Path string (relative to app root)
     20 --   [1 byte]  Null separator
     21 --   [4 bytes] Number of files
     22 --   For each file:
     23 --     [2 bytes] Path length
     24 --     [N bytes] Path string (relative to app root)
     25 --     [4 bytes] Original size
     26 --     [4 bytes] Compressed size
     27 --     [N bytes] DEFLATE compressed data
     28 --   [4 bytes] CRC32 checksum of entire file (excluding this field)
     29 
     30 local LAM = {}
     31 
     32 LAM.VERSION = 2
     33 LAM.MAGIC = "LAM\0"
     34 LAM.KEYS_DIR = "/keys"
     35 
     36 -- Helper: Convert number to 4 bytes (little-endian)
     37 local function uint32_to_bytes(n)
     38     return string.char(
     39         bit.band(n, 0xFF),
     40         bit.band(bit.rshift(n, 8), 0xFF),
     41         bit.band(bit.rshift(n, 16), 0xFF),
     42         bit.band(bit.rshift(n, 24), 0xFF)
     43     )
     44 end
     45 
     46 -- Helper: Convert number to 2 bytes (little-endian)
     47 local function uint16_to_bytes(n)
     48     return string.char(
     49         bit.band(n, 0xFF),
     50         bit.band(bit.rshift(n, 8), 0xFF)
     51     )
     52 end
     53 
     54 -- Helper: Read 4 bytes as uint32 (little-endian)
     55 local function bytes_to_uint32(str, pos)
     56     pos = pos or 1
     57     local b1, b2, b3, b4 = str:byte(pos, pos + 3)
     58     return b1 + b2 * 256 + b3 * 65536 + b4 * 16777216
     59 end
     60 
     61 -- Helper: Read 2 bytes as uint16 (little-endian)
     62 local function bytes_to_uint16(str, pos)
     63     pos = pos or 1
     64     local b1, b2 = str:byte(pos, pos + 1)
     65     return b1 + b2 * 256
     66 end
     67 
     68 -- Helper: Simple CRC32 calculation
     69 local function crc32(data)
     70     if _G.CRC32 then
     71         return _G.CRC32(data)
     72     end
     73     -- Fallback: simple checksum if CRC32 not available
     74     local sum = 0
     75     for i = 1, #data do
     76         sum = bit.bxor(sum, data:byte(i))
     77         sum = bit.band(sum * 31, 0xFFFFFFFF)
     78     end
     79     return sum
     80 end
     81 
     82 -- Helper: List all files and directories recursively
     83 local function listFilesRecursive(basePath, relativePath, files, dirs)
     84     files = files or {}
     85     dirs = dirs or {}
     86     relativePath = relativePath or ""
     87 
     88     local fullPath = basePath
     89     if relativePath ~= "" then
     90         fullPath = basePath .. "/" .. relativePath
     91     end
     92 
     93     -- List directory contents
     94     local contents = nil
     95     if _G.CRamdiskList then
     96         contents = _G.CRamdiskList(fullPath)
     97     end
     98 
     99     if not contents then
    100         return files, dirs
    101     end
    102 
    103     for _, item in ipairs(contents) do
    104         local itemRelPath = relativePath == "" and item.name or (relativePath .. "/" .. item.name)
    105 
    106         if item.type == "directory" then
    107             table.insert(dirs, itemRelPath)
    108             listFilesRecursive(basePath, itemRelPath, files, dirs)
    109         else
    110             table.insert(files, {
    111                 path = itemRelPath,
    112                 fullPath = fullPath .. "/" .. item.name
    113             })
    114         end
    115     end
    116 
    117     return files, dirs
    118 end
    119 
    120 -- Helper: Expand ~ to /home and normalize path
    121 local function normalizePath(path)
    122     if not path then return nil end
    123     -- Expand ~ to /home
    124     if path:sub(1, 1) == "~" then
    125         path = "/home" .. path:sub(2)
    126     end
    127     -- Remove trailing slash
    128     if path:sub(-1) == "/" and #path > 1 then
    129         path = path:sub(1, -2)
    130     end
    131     return path
    132 end
    133 
    134 -- Helper: Check if input looks like a path (contains / or starts with ~ or .)
    135 local function isPath(input)
    136     if not input then return false end
    137     return input:match("^/") or input:match("^~") or input:match("^%./") or input:match("^%.%./")
    138 end
    139 
    140 -- Base64 alphabet
    141 local b64chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
    142 
    143 -- Helper: Base64 encode
    144 local function base64_encode(data)
    145     if not data then return nil end
    146     local result = {}
    147     local pad = 0
    148 
    149     for i = 1, #data, 3 do
    150         local b1 = data:byte(i) or 0
    151         local b2 = data:byte(i + 1) or 0
    152         local b3 = data:byte(i + 2) or 0
    153 
    154         local n = b1 * 65536 + b2 * 256 + b3
    155 
    156         table.insert(result, b64chars:sub(bit.rshift(n, 18) + 1, bit.rshift(n, 18) + 1))
    157         table.insert(result, b64chars:sub(bit.band(bit.rshift(n, 12), 63) + 1, bit.band(bit.rshift(n, 12), 63) + 1))
    158 
    159         if i + 1 <= #data then
    160             table.insert(result, b64chars:sub(bit.band(bit.rshift(n, 6), 63) + 1, bit.band(bit.rshift(n, 6), 63) + 1))
    161         else
    162             table.insert(result, "=")
    163         end
    164 
    165         if i + 2 <= #data then
    166             table.insert(result, b64chars:sub(bit.band(n, 63) + 1, bit.band(n, 63) + 1))
    167         else
    168             table.insert(result, "=")
    169         end
    170     end
    171 
    172     return table.concat(result)
    173 end
    174 
    175 -- Helper: Base64 decode
    176 local function base64_decode(data)
    177     if not data then return nil end
    178 
    179     -- Build reverse lookup
    180     local b64lookup = {}
    181     for i = 1, 64 do
    182         b64lookup[b64chars:sub(i, i)] = i - 1
    183     end
    184 
    185     -- Remove padding and whitespace
    186     data = data:gsub("[^" .. b64chars .. "]", "")
    187 
    188     local result = {}
    189     for i = 1, #data, 4 do
    190         local c1 = b64lookup[data:sub(i, i)] or 0
    191         local c2 = b64lookup[data:sub(i + 1, i + 1)] or 0
    192         local c3 = b64lookup[data:sub(i + 2, i + 2)]
    193         local c4 = b64lookup[data:sub(i + 3, i + 3)]
    194 
    195         local n = c1 * 262144 + c2 * 4096
    196         if c3 then n = n + c3 * 64 end
    197         if c4 then n = n + c4 end
    198 
    199         table.insert(result, string.char(bit.band(bit.rshift(n, 16), 255)))
    200         if c3 then
    201             table.insert(result, string.char(bit.band(bit.rshift(n, 8), 255)))
    202         end
    203         if c4 then
    204             table.insert(result, string.char(bit.band(n, 255)))
    205         end
    206     end
    207 
    208     return table.concat(result)
    209 end
    210 
    211 --- Create a new developer keypair
    212 -- @param developerName Name for the developer (used as folder name)
    213 -- @return true on success, nil and error message on failure
    214 function LAM.newDeveloper(developerName)
    215     if not developerName or developerName == "" then
    216         return nil, "Developer name required"
    217     end
    218 
    219     -- Sanitize name (alphanumeric, underscore, hyphen only)
    220     if not developerName:match("^[%w_%-]+$") then
    221         return nil, "Developer name can only contain letters, numbers, underscores, and hyphens"
    222     end
    223 
    224     -- Check if crypto is available
    225     if not _G.crypto or not _G.crypto.ed25519_keypair then
    226         return nil, "Crypto library not available (ed25519_keypair)"
    227     end
    228 
    229     -- Create /keys directory if it doesn't exist
    230     if _G.CRamdiskMkdir and not _G.CRamdiskExists(LAM.KEYS_DIR) then
    231         _G.CRamdiskMkdir(LAM.KEYS_DIR)
    232     end
    233 
    234     -- Create developer directory
    235     local devDir = LAM.KEYS_DIR .. "/" .. developerName
    236     if _G.CRamdiskExists and _G.CRamdiskExists(devDir) then
    237         return nil, "Developer '" .. developerName .. "' already exists"
    238     end
    239 
    240     if not _G.CRamdiskMkdir(devDir) then
    241         return nil, "Failed to create directory: " .. devDir
    242     end
    243 
    244     -- Generate keypair
    245     local publicKey, secretKey = _G.crypto.ed25519_keypair()
    246 
    247     if not publicKey or not secretKey then
    248         return nil, "Failed to generate keypair"
    249     end
    250 
    251     -- Save public key
    252     local pubPath = devDir .. "/public.key"
    253     local pubHandle = _G.CRamdiskOpen(pubPath, "w")
    254     if not pubHandle then
    255         return nil, "Failed to create public key file"
    256     end
    257     _G.CRamdiskWrite(pubHandle, publicKey)
    258     _G.CRamdiskClose(pubHandle)
    259 
    260     -- Save secret key
    261     local secPath = devDir .. "/secret.key"
    262     local secHandle = _G.CRamdiskOpen(secPath, "w")
    263     if not secHandle then
    264         return nil, "Failed to create secret key file"
    265     end
    266     _G.CRamdiskWrite(secHandle, secretKey)
    267     _G.CRamdiskClose(secHandle)
    268 
    269     return true, {
    270         name = developerName,
    271         path = devDir,
    272         publicKey = publicKey
    273     }
    274 end
    275 
    276 --- Load developer keys
    277 -- @param developerName Name of the developer
    278 -- @return publicKey, secretKey (base64) or nil, error
    279 function LAM.loadDeveloperKeys(developerName)
    280     if not developerName then
    281         return nil, nil, "Developer name required"
    282     end
    283 
    284     local devDir = LAM.KEYS_DIR .. "/" .. developerName
    285 
    286     if not _G.CRamdiskExists or not _G.CRamdiskExists(devDir) then
    287         return nil, nil, "Developer not found: " .. developerName
    288     end
    289 
    290     -- Load public key
    291     local pubPath = devDir .. "/public.key"
    292     local pubHandle = _G.CRamdiskOpen(pubPath, "r")
    293     if not pubHandle then
    294         return nil, nil, "Failed to open public key"
    295     end
    296     local publicKey = _G.CRamdiskRead(pubHandle)
    297     _G.CRamdiskClose(pubHandle)
    298 
    299     -- Load secret key
    300     local secPath = devDir .. "/secret.key"
    301     local secHandle = _G.CRamdiskOpen(secPath, "r")
    302     if not secHandle then
    303         return nil, nil, "Failed to open secret key"
    304     end
    305     local secretKey = _G.CRamdiskRead(secHandle)
    306     _G.CRamdiskClose(secHandle)
    307 
    308     return publicKey, secretKey
    309 end
    310 
    311 --- List all developers
    312 -- @return Table of developer names
    313 function LAM.listDevelopers()
    314     local developers = {}
    315 
    316     if not _G.CRamdiskList or not _G.CRamdiskExists(LAM.KEYS_DIR) then
    317         return developers
    318     end
    319 
    320     local contents = _G.CRamdiskList(LAM.KEYS_DIR)
    321     if contents then
    322         for _, item in ipairs(contents) do
    323             if item.type == "directory" then
    324                 -- Check if it has key files
    325                 local pubPath = LAM.KEYS_DIR .. "/" .. item.name .. "/public.key"
    326                 if _G.CRamdiskExists(pubPath) then
    327                     table.insert(developers, item.name)
    328                 end
    329             end
    330         end
    331     end
    332 
    333     return developers
    334 end
    335 
    336 --- Package an application into a .lam file
    337 -- @param appIdOrPath Application ID (e.g., "com.luajitos.moonbrowser") or path (e.g., "~/Documents/myapp/")
    338 -- @param outputPath Output file path (e.g., "/home/moonbrowser.lam")
    339 -- @param options Optional table with:
    340 --   - developer: Developer name to sign the package with
    341 -- @return true on success, nil and error message on failure
    342 function LAM.package(appIdOrPath, outputPath, options)
    343     options = options or {}
    344     if not appIdOrPath then
    345         return nil, "Application ID or path required"
    346     end
    347 
    348     local appPath
    349     local appId
    350 
    351     -- Check if input is a path or an app ID
    352     if isPath(appIdOrPath) then
    353         -- It's a path - normalize it
    354         appPath = normalizePath(appIdOrPath)
    355 
    356         -- Check if path exists
    357         if not _G.CRamdiskExists or not _G.CRamdiskExists(appPath) then
    358             return nil, "Directory not found: " .. appPath
    359         end
    360 
    361         -- Extract app name from path for output filename
    362         appId = appPath:match("([^/]+)$") or "app"
    363     else
    364         -- It's an app ID
    365         appId = appIdOrPath
    366 
    367         -- Add prefix if needed
    368         if not appId:match("%.") then
    369             appId = "com.luajitos." .. appId
    370         end
    371 
    372         appPath = "/apps/" .. appId
    373 
    374         -- Check if app exists
    375         if not _G.CRamdiskExists or not _G.CRamdiskExists(appPath) then
    376             return nil, "Application not found: " .. appPath
    377         end
    378     end
    379 
    380     -- Read manifest
    381     local manifestPath = appPath .. "/manifest.lua"
    382     if not _G.CRamdiskExists(manifestPath) then
    383         return nil, "Manifest not found: " .. manifestPath
    384     end
    385 
    386     local manifestHandle = _G.CRamdiskOpen(manifestPath, "r")
    387     if not manifestHandle then
    388         return nil, "Failed to open manifest"
    389     end
    390     local manifestContent = _G.CRamdiskRead(manifestHandle)
    391     _G.CRamdiskClose(manifestHandle)
    392 
    393     if not manifestContent then
    394         return nil, "Failed to read manifest"
    395     end
    396 
    397     -- List all files and directories
    398     local files, dirs = listFilesRecursive(appPath, "")
    399 
    400     -- Load developer keys if signing is requested
    401     local developerName = options.developer
    402     local publicKey, secretKey
    403     if developerName then
    404         publicKey, secretKey = LAM.loadDeveloperKeys(developerName)
    405         if not publicKey or not secretKey then
    406             return nil, "Failed to load developer keys for: " .. developerName
    407         end
    408     end
    409 
    410     -- Build the package content (everything after signature header)
    411     local contentParts = {}
    412 
    413     -- Null separator after signature header
    414     table.insert(contentParts, "\0")
    415 
    416     -- Version
    417     table.insert(contentParts, uint32_to_bytes(LAM.VERSION))
    418 
    419     -- Manifest
    420     table.insert(contentParts, uint32_to_bytes(#manifestContent))
    421     table.insert(contentParts, manifestContent)
    422     table.insert(contentParts, "\0")  -- Null separator
    423 
    424     -- Directories
    425     table.insert(contentParts, uint32_to_bytes(#dirs))
    426     for _, dirPath in ipairs(dirs) do
    427         table.insert(contentParts, uint16_to_bytes(#dirPath))
    428         table.insert(contentParts, dirPath)
    429     end
    430     table.insert(contentParts, "\0")  -- Null separator
    431 
    432     -- Files
    433     table.insert(contentParts, uint32_to_bytes(#files))
    434     for _, file in ipairs(files) do
    435         -- Read file content
    436         local fileHandle = _G.CRamdiskOpen(file.fullPath, "r")
    437         if not fileHandle then
    438             return nil, "Failed to open file: " .. file.fullPath
    439         end
    440         local fileContent = _G.CRamdiskRead(fileHandle)
    441         _G.CRamdiskClose(fileHandle)
    442 
    443         if not fileContent then
    444             return nil, "Failed to read file: " .. file.fullPath
    445         end
    446 
    447         -- Compress file content
    448         local compressed = fileContent
    449         local originalSize = #fileContent
    450 
    451         if _G.DeflateCompress and originalSize > 0 then
    452             local result = _G.DeflateCompress(fileContent, 6)  -- Level 6 compression
    453             if result and #result < originalSize then
    454                 compressed = result
    455             end
    456         end
    457 
    458         -- Write file entry
    459         table.insert(contentParts, uint16_to_bytes(#file.path))
    460         table.insert(contentParts, file.path)
    461         table.insert(contentParts, uint32_to_bytes(originalSize))
    462         table.insert(contentParts, uint32_to_bytes(#compressed))
    463         table.insert(contentParts, compressed)
    464     end
    465 
    466     -- Combine content parts
    467     local contentData = table.concat(contentParts)
    468 
    469     -- Build final package with header
    470     local headerParts = {}
    471 
    472     -- Magic
    473     table.insert(headerParts, LAM.MAGIC)
    474 
    475     -- Has signature flag
    476     if developerName then
    477         table.insert(headerParts, string.char(1))  -- Has signature
    478 
    479         -- Sign the content data
    480         local secretKeyRaw = base64_decode(secretKey)
    481         if not secretKeyRaw or #secretKeyRaw ~= 64 then
    482             return nil, "Invalid secret key format"
    483         end
    484 
    485         local signature
    486         if _G.crypto and _G.crypto.ed25519_sign then
    487             local sigB64 = _G.crypto.ed25519_sign(contentData, secretKeyRaw)
    488             signature = base64_decode(sigB64)
    489         end
    490 
    491         if not signature or #signature ~= 64 then
    492             return nil, "Failed to sign package"
    493         end
    494 
    495         table.insert(headerParts, signature)  -- 64-byte signature
    496 
    497         -- Developer name
    498         table.insert(headerParts, uint16_to_bytes(#developerName))
    499         table.insert(headerParts, developerName)
    500     else
    501         table.insert(headerParts, string.char(0))  -- No signature
    502     end
    503 
    504     -- Combine header and content
    505     local packageData = table.concat(headerParts) .. contentData
    506 
    507     -- Calculate and append checksum
    508     local checksum = crc32(packageData)
    509     packageData = packageData .. uint32_to_bytes(checksum)
    510 
    511     -- Write to output file
    512     if not outputPath then
    513         outputPath = "/home/" .. appId:match("([^.]+)$") .. ".lam"
    514     end
    515 
    516     local outHandle = _G.CRamdiskOpen(outputPath, "w")
    517     if not outHandle then
    518         return nil, "Failed to create output file: " .. outputPath
    519     end
    520 
    521     _G.CRamdiskWrite(outHandle, packageData)
    522     _G.CRamdiskClose(outHandle)
    523 
    524     return true, {
    525         path = outputPath,
    526         size = #packageData,
    527         files = #files,
    528         dirs = #dirs,
    529         manifest = manifestContent
    530     }
    531 end
    532 
    533 --- Install an application from a .lam file
    534 -- @param lamPath Path to the .lam file
    535 -- @param options Optional table with:
    536 --   - force: Overwrite existing installation
    537 --   - skipPostInstall: Don't run post-install script
    538 --   - requireSignature: Fail if package is not signed
    539 -- @return true on success, nil and error message on failure
    540 function LAM.install(lamPath, options)
    541     options = options or {}
    542 
    543     if not lamPath then
    544         return nil, "LAM file path required"
    545     end
    546 
    547     -- Read the package file
    548     if not _G.CRamdiskExists or not _G.CRamdiskExists(lamPath) then
    549         return nil, "File not found: " .. lamPath
    550     end
    551 
    552     local handle = _G.CRamdiskOpen(lamPath, "r")
    553     if not handle then
    554         return nil, "Failed to open file: " .. lamPath
    555     end
    556     local data = _G.CRamdiskRead(handle)
    557     _G.CRamdiskClose(handle)
    558 
    559     if not data then
    560         return nil, "Failed to read file"
    561     end
    562 
    563     local pos = 1
    564 
    565     -- Verify magic
    566     if data:sub(pos, pos + 3) ~= LAM.MAGIC then
    567         return nil, "Invalid LAM file (bad magic)"
    568     end
    569     pos = pos + 4
    570 
    571     -- Verify checksum first
    572     local storedChecksum = bytes_to_uint32(data, #data - 3)
    573     local calculatedChecksum = crc32(data:sub(1, #data - 4))
    574     if storedChecksum ~= calculatedChecksum then
    575         return nil, "Checksum mismatch (file may be corrupted)"
    576     end
    577 
    578     -- Read signature flag
    579     local hasSig = data:byte(pos) == 1
    580     pos = pos + 1
    581 
    582     local signature, developerName, signatureValid
    583     local contentStart
    584 
    585     if hasSig then
    586         -- Read signature (64 bytes)
    587         signature = data:sub(pos, pos + 63)
    588         pos = pos + 64
    589 
    590         -- Read developer name
    591         local devNameLen = bytes_to_uint16(data, pos)
    592         pos = pos + 2
    593         developerName = data:sub(pos, pos + devNameLen - 1)
    594         pos = pos + devNameLen
    595 
    596         -- Content starts after developer name (at the null separator)
    597         contentStart = pos
    598 
    599         -- Verify signature
    600         local contentData = data:sub(contentStart, #data - 4)  -- Exclude checksum
    601         local publicKey, _ = LAM.loadDeveloperKeys(developerName)
    602 
    603         if publicKey then
    604             local publicKeyRaw = base64_decode(publicKey)
    605             if publicKeyRaw and #publicKeyRaw == 32 and _G.crypto and _G.crypto.ed25519_verify then
    606                 signatureValid = _G.crypto.ed25519_verify(contentData, signature, publicKeyRaw)
    607             end
    608         end
    609 
    610         if not signatureValid then
    611             print("WARNING: Package signature could not be verified for developer: " .. developerName)
    612             if options.requireSignature then
    613                 return nil, "Signature verification failed"
    614             end
    615         else
    616             print("Package signed by: " .. developerName .. " (verified)")
    617         end
    618     else
    619         contentStart = pos
    620         if options.requireSignature then
    621             return nil, "Package is not signed (signature required)"
    622         end
    623     end
    624 
    625     -- Skip null separator
    626     pos = pos + 1
    627 
    628     -- Read version
    629     local version = bytes_to_uint32(data, pos)
    630     pos = pos + 4
    631     if version > LAM.VERSION then
    632         return nil, "Unsupported LAM version: " .. version
    633     end
    634 
    635     -- Read manifest
    636     local manifestLen = bytes_to_uint32(data, pos)
    637     pos = pos + 4
    638     local manifestContent = data:sub(pos, pos + manifestLen - 1)
    639     pos = pos + manifestLen
    640 
    641     -- Skip null separator
    642     pos = pos + 1
    643 
    644     -- Parse manifest to get app ID
    645     local manifestFunc, err = loadstring("return " .. manifestContent, "manifest")
    646     if not manifestFunc then
    647         -- Try without "return"
    648         manifestFunc, err = loadstring(manifestContent, "manifest")
    649     end
    650 
    651     if not manifestFunc then
    652         return nil, "Failed to parse manifest: " .. tostring(err)
    653     end
    654 
    655     local ok, manifest = pcall(manifestFunc)
    656     if not ok then
    657         return nil, "Failed to execute manifest: " .. tostring(manifest)
    658     end
    659 
    660     -- Determine app ID from manifest or filename
    661     local appId = manifest.id or manifest.name
    662     if not appId then
    663         -- Try to extract from filename
    664         appId = lamPath:match("([^/]+)%.lam$")
    665     end
    666     if not appId then
    667         return nil, "Could not determine application ID"
    668     end
    669 
    670     -- If appId doesn't have dots, assume com.luajitos prefix
    671     if not appId:match("%.") then
    672         appId = "com.luajitos." .. appId
    673     end
    674 
    675     local appPath = "/apps/" .. appId
    676 
    677     -- Check if already installed
    678     if _G.CRamdiskExists(appPath) and not options.force then
    679         return nil, "Application already installed: " .. appId .. " (use force=true to overwrite)"
    680     end
    681 
    682     -- Create app directory
    683     if not _G.CRamdiskExists(appPath) then
    684         _G.CRamdiskMkdir(appPath)
    685     end
    686 
    687     -- Read directories
    688     local numDirs = bytes_to_uint32(data, pos)
    689     pos = pos + 4
    690 
    691     local dirs = {}
    692     for i = 1, numDirs do
    693         local pathLen = bytes_to_uint16(data, pos)
    694         pos = pos + 2
    695         local dirPath = data:sub(pos, pos + pathLen - 1)
    696         pos = pos + pathLen
    697         table.insert(dirs, dirPath)
    698     end
    699 
    700     -- Skip null separator
    701     pos = pos + 1
    702 
    703     -- Create directories
    704     for _, dirPath in ipairs(dirs) do
    705         local fullPath = appPath .. "/" .. dirPath
    706         if not _G.CRamdiskExists(fullPath) then
    707             _G.CRamdiskMkdir(fullPath)
    708         end
    709     end
    710 
    711     -- Read files
    712     local numFiles = bytes_to_uint32(data, pos)
    713     pos = pos + 4
    714 
    715     local installedFiles = {}
    716     for i = 1, numFiles do
    717         local pathLen = bytes_to_uint16(data, pos)
    718         pos = pos + 2
    719         local filePath = data:sub(pos, pos + pathLen - 1)
    720         pos = pos + pathLen
    721 
    722         local originalSize = bytes_to_uint32(data, pos)
    723         pos = pos + 4
    724         local compressedSize = bytes_to_uint32(data, pos)
    725         pos = pos + 4
    726 
    727         local compressedData = data:sub(pos, pos + compressedSize - 1)
    728         pos = pos + compressedSize
    729 
    730         -- Decompress if needed
    731         local fileContent
    732         if compressedSize < originalSize and _G.DeflateDecompress then
    733             fileContent = _G.DeflateDecompress(compressedData)
    734             if not fileContent then
    735                 return nil, "Failed to decompress file: " .. filePath
    736             end
    737         else
    738             fileContent = compressedData
    739         end
    740 
    741         -- Write file
    742         local fullPath = appPath .. "/" .. filePath
    743 
    744         -- Ensure parent directory exists
    745         local parentDir = fullPath:match("(.*/)[^/]+$")
    746         if parentDir and not _G.CRamdiskExists(parentDir) then
    747             _G.CRamdiskMkdir(parentDir)
    748         end
    749 
    750         local fileHandle = _G.CRamdiskOpen(fullPath, "w")
    751         if not fileHandle then
    752             return nil, "Failed to create file: " .. fullPath
    753         end
    754         _G.CRamdiskWrite(fileHandle, fileContent)
    755         _G.CRamdiskClose(fileHandle)
    756 
    757         table.insert(installedFiles, filePath)
    758     end
    759 
    760     -- Write manifest (it's stored in package but needs to be written to disk)
    761     local manifestHandle = _G.CRamdiskOpen(appPath .. "/manifest.lua", "w")
    762     if manifestHandle then
    763         _G.CRamdiskWrite(manifestHandle, manifestContent)
    764         _G.CRamdiskClose(manifestHandle)
    765     end
    766 
    767     -- Run post-install script if specified
    768     if manifest.postInstall and not options.skipPostInstall then
    769         local postInstallPath = appPath .. "/src/" .. manifest.postInstall
    770         if _G.CRamdiskExists(postInstallPath) then
    771             -- Run the post-install script in the app's sandbox
    772             if _G.run and _G.run.execute then
    773                 local success, result = pcall(function()
    774                     return _G.run.execute(appId, _G.fsRoot)
    775                 end)
    776                 if not success then
    777                     print("Warning: Post-install script failed: " .. tostring(result))
    778                 end
    779             end
    780         end
    781     end
    782 
    783     return true, {
    784         appId = appId,
    785         path = appPath,
    786         files = #installedFiles,
    787         dirs = #dirs,
    788         manifest = manifest
    789     }
    790 end
    791 
    792 --- List installed applications
    793 -- @return Table of installed app IDs
    794 function LAM.list()
    795     local apps = {}
    796 
    797     if not _G.CRamdiskList then
    798         return apps
    799     end
    800 
    801     local contents = _G.CRamdiskList("/apps")
    802     if contents then
    803         for _, item in ipairs(contents) do
    804             if item.type == "directory" then
    805                 -- Check if it has a manifest
    806                 local manifestPath = "/apps/" .. item.name .. "/manifest.lua"
    807                 if _G.CRamdiskExists(manifestPath) then
    808                     table.insert(apps, item.name)
    809                 end
    810             end
    811         end
    812     end
    813 
    814     return apps
    815 end
    816 
    817 --- Get information about an installed application
    818 -- @param appId Application ID
    819 -- @return Manifest table or nil
    820 function LAM.info(appId)
    821     if not appId then
    822         return nil, "Application ID required"
    823     end
    824 
    825     local manifestPath = "/apps/" .. appId .. "/manifest.lua"
    826 
    827     if not _G.CRamdiskExists or not _G.CRamdiskExists(manifestPath) then
    828         return nil, "Application not found: " .. appId
    829     end
    830 
    831     local handle = _G.CRamdiskOpen(manifestPath, "r")
    832     if not handle then
    833         return nil, "Failed to open manifest"
    834     end
    835     local content = _G.CRamdiskRead(handle)
    836     _G.CRamdiskClose(handle)
    837 
    838     local func, err = loadstring("return " .. content, "manifest")
    839     if not func then
    840         func, err = loadstring(content, "manifest")
    841     end
    842 
    843     if not func then
    844         return nil, "Failed to parse manifest: " .. tostring(err)
    845     end
    846 
    847     local ok, manifest = pcall(func)
    848     if not ok then
    849         return nil, "Failed to execute manifest: " .. tostring(manifest)
    850     end
    851 
    852     return manifest
    853 end
    854 
    855 --- Uninstall an application
    856 -- @param appId Application ID
    857 -- @return true on success, nil and error message on failure
    858 function LAM.uninstall(appId)
    859     if not appId then
    860         return nil, "Application ID required"
    861     end
    862 
    863     local appPath = "/apps/" .. appId
    864 
    865     if not _G.CRamdiskExists or not _G.CRamdiskExists(appPath) then
    866         return nil, "Application not found: " .. appId
    867     end
    868 
    869     -- Get manifest to check for preUninstall script
    870     local manifest = LAM.info(appId)
    871 
    872     -- Run pre-uninstall script if specified
    873     if manifest and manifest.preUninstall then
    874         local preUninstallPath = appPath .. "/src/" .. manifest.preUninstall
    875         if _G.CRamdiskExists(preUninstallPath) then
    876             -- Could run the script here
    877             print("Running pre-uninstall script...")
    878         end
    879     end
    880 
    881     -- Remove all files and directories recursively
    882     local function removeRecursive(path)
    883         local contents = _G.CRamdiskList(path)
    884         if contents then
    885             for _, item in ipairs(contents) do
    886                 local fullPath = path .. "/" .. item.name
    887                 if item.type == "directory" then
    888                     removeRecursive(fullPath)
    889                 else
    890                     _G.CRamdiskRemove(fullPath)
    891                 end
    892             end
    893         end
    894         _G.CRamdiskRemove(path)
    895     end
    896 
    897     removeRecursive(appPath)
    898 
    899     return true
    900 end
    901 
    902 --- CLI interface for LAM
    903 -- @param args Command line arguments
    904 function LAM.cli(args)
    905     if not args or #args == 0 then
    906         print("LuajitOS Application Manager (LAM)")
    907         print("")
    908         print("Usage:")
    909         print("  lam package <appId|path> [output] [--sign <dev>]")
    910         print("                                    - Package an app into a .lam file")
    911         print("  lam install <file.lam> [--force]  - Install an app from a .lam file")
    912         print("  lam uninstall <appId>             - Uninstall an application")
    913         print("  lam list                          - List installed applications")
    914         print("  lam info <appId>                  - Show app information")
    915         print("  lam new-developer <name>          - Create a new developer keypair")
    916         print("  lam list-developers               - List all developers")
    917         print("")
    918         print("Examples:")
    919         print("  lam package moonbrowser           - Package installed app (unsigned)")
    920         print("  lam package moonbrowser --sign me - Package and sign with 'me' key")
    921         print("  lam package ~/Documents/myapp/    - Package from directory")
    922         print("  lam new-developer myname          - Create keypair in /keys/myname/")
    923         print("")
    924         return
    925     end
    926 
    927     local cmd = args[1]
    928 
    929     if cmd == "new-developer" then
    930         local devName = args[2]
    931 
    932         if not devName then
    933             print("Error: Developer name required")
    934             print("Usage: lam new-developer <name>")
    935             return
    936         end
    937 
    938         print("Creating developer keypair for: " .. devName)
    939         local ok, result = LAM.newDeveloper(devName)
    940 
    941         if ok then
    942             print("Developer created: " .. result.name)
    943             print("  Path: " .. result.path)
    944             print("  Public key: " .. result.publicKey)
    945             print("")
    946             print("Keep your secret key safe! It's stored at:")
    947             print("  " .. result.path .. "/secret.key")
    948         else
    949             print("Error: " .. tostring(result))
    950         end
    951 
    952     elseif cmd == "list-developers" then
    953         local developers = LAM.listDevelopers()
    954 
    955         if #developers == 0 then
    956             print("No developers registered")
    957             print("Use 'lam new-developer <name>' to create a keypair")
    958         else
    959             print("Registered developers:")
    960             for _, name in ipairs(developers) do
    961                 local pubKey, _ = LAM.loadDeveloperKeys(name)
    962                 print("  " .. name)
    963                 if pubKey then
    964                     print("    Public key: " .. pubKey:sub(1, 20) .. "...")
    965                 end
    966             end
    967         end
    968 
    969     elseif cmd == "package" then
    970         local appIdOrPath = args[2]
    971         local outputPath = nil
    972         local developer = nil
    973 
    974         -- Parse remaining args for output path and --sign
    975         local i = 3
    976         while i <= #args do
    977             if args[i] == "--sign" and args[i + 1] then
    978                 developer = args[i + 1]
    979                 i = i + 2
    980             elseif not outputPath and not args[i]:match("^%-") then
    981                 outputPath = args[i]
    982                 i = i + 1
    983             else
    984                 i = i + 1
    985             end
    986         end
    987 
    988         if not appIdOrPath then
    989             print("Error: Application ID or path required")
    990             print("Usage: lam package <appId|path> [outputPath] [--sign <developer>]")
    991             return
    992         end
    993 
    994         if developer then
    995             print("Packaging " .. appIdOrPath .. " (signed by " .. developer .. ")...")
    996         else
    997             print("Packaging " .. appIdOrPath .. " (unsigned)...")
    998         end
    999 
   1000         local ok, result = LAM.package(appIdOrPath, outputPath, { developer = developer })
   1001 
   1002         if ok then
   1003             print("Package created: " .. result.path)
   1004             print("  Size: " .. result.size .. " bytes")
   1005             print("  Files: " .. result.files)
   1006             print("  Directories: " .. result.dirs)
   1007         else
   1008             print("Error: " .. tostring(result))
   1009         end
   1010 
   1011     elseif cmd == "install" then
   1012         local lamPath = args[2]
   1013         local force = args[3] == "--force" or args.force
   1014 
   1015         if not lamPath then
   1016             print("Error: LAM file path required")
   1017             print("Usage: lam install <file.lam> [--force]")
   1018             return
   1019         end
   1020 
   1021         print("Installing from " .. lamPath .. "...")
   1022         local ok, result = LAM.install(lamPath, { force = force })
   1023 
   1024         if ok then
   1025             print("Installed: " .. result.appId)
   1026             print("  Path: " .. result.path)
   1027             print("  Files: " .. result.files)
   1028             print("  Directories: " .. result.dirs)
   1029         else
   1030             print("Error: " .. tostring(result))
   1031         end
   1032 
   1033     elseif cmd == "uninstall" then
   1034         local appId = args[2]
   1035 
   1036         if not appId then
   1037             print("Error: Application ID required")
   1038             print("Usage: lam uninstall <appId>")
   1039             return
   1040         end
   1041 
   1042         -- Add prefix if needed
   1043         if not appId:match("%.") then
   1044             appId = "com.luajitos." .. appId
   1045         end
   1046 
   1047         print("Uninstalling " .. appId .. "...")
   1048         local ok, err = LAM.uninstall(appId)
   1049 
   1050         if ok then
   1051             print("Uninstalled: " .. appId)
   1052         else
   1053             print("Error: " .. tostring(err))
   1054         end
   1055 
   1056     elseif cmd == "list" then
   1057         local apps = LAM.list()
   1058 
   1059         if #apps == 0 then
   1060             print("No applications installed")
   1061         else
   1062             print("Installed applications:")
   1063             for _, appId in ipairs(apps) do
   1064                 local manifest = LAM.info(appId)
   1065                 local name = manifest and manifest.name or appId
   1066                 local version = manifest and manifest.version or "?"
   1067                 print("  " .. appId .. " (" .. name .. " v" .. version .. ")")
   1068             end
   1069         end
   1070 
   1071     elseif cmd == "info" then
   1072         local appId = args[2]
   1073 
   1074         if not appId then
   1075             print("Error: Application ID required")
   1076             print("Usage: lam info <appId>")
   1077             return
   1078         end
   1079 
   1080         -- Add prefix if needed
   1081         if not appId:match("%.") then
   1082             appId = "com.luajitos." .. appId
   1083         end
   1084 
   1085         local manifest, err = LAM.info(appId)
   1086 
   1087         if manifest then
   1088             print("Application: " .. appId)
   1089             print("  Name: " .. (manifest.name or "?"))
   1090             print("  Version: " .. (manifest.version or "?"))
   1091             print("  Developer: " .. (manifest.developer or "?"))
   1092             print("  Description: " .. (manifest.description or "?"))
   1093             print("  Entry: " .. (manifest.entry or "init.lua"))
   1094             print("  Type: " .. (manifest.type or "gui"))
   1095             if manifest.permissions then
   1096                 print("  Permissions: " .. table.concat(manifest.permissions, ", "))
   1097             end
   1098         else
   1099             print("Error: " .. tostring(err))
   1100         end
   1101 
   1102     else
   1103         print("Unknown command: " .. cmd)
   1104         print("Run 'lam' without arguments for usage")
   1105     end
   1106 end
   1107 
   1108 return LAM