/****************************************************************************** __ ____ __ / / ___ ____ _/ __/_____________ _/ /__ / / / _ \/ __ `/ /_/ ___/ ___/ __ `/ / _ \ / /___/ __/ /_/ / __(__ ) /__/ /_/ / / __/ /_____/\___/\__,_/_/ /____/\___/\__,_/_/\___/ (C)opyright 2025, Leafscale, LLC - https://www.leafscale.com Project: Zygaena Filename: package.reef Authors: Chris Tusa License: Description: Package archive handling - extract, read metadata, install files ******************************************************************************/ module core.package import types import io.file import io.dir import io.path import core.str import encoding.toml import util.archive import util.mtree import util.checksum import sys.process import fs.stat import fs.perm import fs.link import fs.ops import core.result as res export // Package extraction result type ExtractResult // Extract a package to a temporary directory fn extract_package(pkg_path: string, dest_dir: string): ExtractResult // Read package info from an extracted package or archive fn read_pkginfo(pkg_path: string): types.PackageInfo // Read file list from an extracted package (legacy .FOOTPRINT or .MANIFEST) fn read_footprint(pkg_path: string, files: [string], max_count: int): int // Read manifest entries from an extracted package fn read_manifest(pkg_dir: string, entries: [mtree.ManifestEntry], max_count: int): int // Install files from extracted package to root (manifest-driven) fn install_files(src_dir: string, root: string): bool // Remove files listed in footprint from root fn remove_files(files: [string], file_count: int, root: string): bool // Remove files using manifest entries (with explicit directory tracking) fn remove_files_manifest(entries: [mtree.ManifestEntry], entry_count: int, root: string): bool // Verify package integrity (check all files exist in extracted dir) fn verify_package(pkg_dir: string): bool // Install files with old manifest for upgrade config protection fn install_files_upgrade(src_dir: string, root: string, old_entries: [mtree.ManifestEntry], old_entry_count: int): bool // Script execution context (chroot-confined maintainer scripts) type ScriptCtx fn new_script_ctx(root: string, do_chroot: bool): ScriptCtx fn script_wants_chroot(root: string, chroot_scripts: bool): bool fn build_script_cmd(ctx: ScriptCtx, script_path: string, script_name: string, args: string, is_sh: bool): string fn resolve_script_ctx(root: string, chroot_scripts: bool, caller_is_root: bool): ScriptCtx // Install scripts support (version passed as $VERSION to scripts) fn has_scripts(pkg_dir: string): bool fn run_pre_install(ctx: ScriptCtx, pkg_dir: string, version: string): bool fn run_post_install(ctx: ScriptCtx, pkg_dir: string, version: string): bool fn run_pre_remove(ctx: ScriptCtx, pkg_dir: string, version: string): bool fn run_post_remove(ctx: ScriptCtx, pkg_dir: string, version: string): bool // Upgrade scripts ($OLD_VERSION $NEW_VERSION passed to scripts) fn run_pre_upgrade(ctx: ScriptCtx, pkg_dir: string, old_version: string, new_version: string): bool fn run_post_upgrade(ctx: ScriptCtx, pkg_dir: string, old_version: string, new_version: string): bool // Run scripts from a stored scripts directory (for remove/upgrade operations) fn run_stored_pre_remove(ctx: ScriptCtx, scripts_dir: string, version: string): bool fn run_stored_post_remove(ctx: ScriptCtx, scripts_dir: string, version: string): bool fn run_stored_pre_upgrade(ctx: ScriptCtx, scripts_dir: string, old_version: string, new_version: string): bool fn run_stored_post_upgrade(ctx: ScriptCtx, scripts_dir: string, old_version: string, new_version: string): bool // Config file protection fn read_config_files(pkg_dir: string, config_files: [string], max_count: int): int fn is_config_file(rel_path: string, config_files: [string], config_count: int): bool fn install_config_file(src_path: string, dest_path: string, manifest_sha256: string, stored_sha256: string): bool end export // Extraction result type ExtractResult = struct success: bool extract_dir: string error: string end ExtractResult // Create a failed extraction result fn extract_error(msg: string): ExtractResult return ExtractResult{ success: false, extract_dir: "", error: msg } end extract_error // Create a successful extraction result fn extract_success(dir: string): ExtractResult return ExtractResult{ success: true, extract_dir: dir, error: "" } end extract_success // Extract a package archive to destination directory fn extract_package(pkg_path: string, dest_dir: string): ExtractResult // Verify package exists if not file.fileExists(pkg_path) return extract_error("Package file not found: " + pkg_path) end if // Ensure destination directory exists if not dir.dir_exists(dest_dir) if not res.is_ok(dir.create_dir_all(dest_dir)) return extract_error("Failed to create extraction directory: " + dest_dir) end if end if // Extract using archive module if not archive.extract(pkg_path, dest_dir) return extract_error("Failed to extract package: " + pkg_path) end if // Verify .PKGINFO exists let pkginfo_path = path.join_path(dest_dir, ".PKGINFO") if not file.fileExists(pkginfo_path) return extract_error("Invalid package: missing .PKGINFO") end if return extract_success(dest_dir) end extract_package // Read package info from .PKGINFO in an extracted package directory fn read_pkginfo(pkg_dir: string): types.PackageInfo mut info = types.new_package_info() let pkginfo_path = path.join_path(pkg_dir, ".PKGINFO") if not file.fileExists(pkginfo_path) return info end if let content = res.unwrap_or(file.readFile(pkginfo_path), "") if str.length(content) == 0 return info end if // Parse TOML let keys = toml.toml_alloc_keys() let vals = toml.toml_alloc_values() let entry_count = res.unwrap_or(toml.toml_parse(content, keys, vals), 0) if entry_count == 0 return info end if // Extract fields info.name = toml.toml_get(keys, vals, entry_count, "package.name") info.version = toml.toml_get(keys, vals, entry_count, "package.version") info.release = toml.toml_get_int(keys, vals, entry_count, "package.release") info.description = toml.toml_get(keys, vals, entry_count, "package.description") info.url = toml.toml_get(keys, vals, entry_count, "package.url") info.license = toml.toml_get(keys, vals, entry_count, "package.license") info.maintainer = toml.toml_get(keys, vals, entry_count, "package.maintainer") info.arch = toml.toml_get(keys, vals, entry_count, "package.arch") // Parse [dependencies] section (optional — legacy packages won't have it) if toml.toml_has_key(keys, vals, entry_count, "dependencies.runtime") let rt_val = toml.toml_get(keys, vals, entry_count, "dependencies.runtime") info.runtime_deps_count = parse_toml_array(rt_val, info.runtime_deps, 64) end if if toml.toml_has_key(keys, vals, entry_count, "dependencies.build") let bd_val = toml.toml_get(keys, vals, entry_count, "dependencies.build") info.build_deps_count = parse_toml_array(bd_val, info.build_deps, 64) end if return info end read_pkginfo // Read file list from .MANIFEST (v2) or .FOOTPRINT (legacy) // Returns a flat list of relative file paths (files and symlinks only) fn read_footprint(pkg_dir: string, files: [string], max_count: int): int // Try .MANIFEST first (v2 format) let manifest_path = path.join_path(pkg_dir, ".MANIFEST") if file.fileExists(manifest_path) let content = res.unwrap_or(file.readFile(manifest_path), "") if str.length(content) > 0 mut entries: [mtree.ManifestEntry] = new [mtree.ManifestEntry](max_count) let entry_count = mtree.parse_manifest(content, entries, max_count) mut count = 0 mut i = 0 while i < entry_count and count < max_count let etype = mtree.entry_type(entries[i]) // Only include files and symlinks (matches legacy behavior) if str.equals(etype, "file") or str.equals(etype, "link") let epath = mtree.entry_path(entries[i]) if str.starts_with(epath, "./") files[count] = str.substring(epath, 2, str.length(epath) - 2) else files[count] = epath end if count = count + 1 end if i = i + 1 end while return count end if end if // Fallback: legacy .FOOTPRINT let footprint_path = path.join_path(pkg_dir, ".FOOTPRINT") if not file.fileExists(footprint_path) return 0 end if let content = res.unwrap_or(file.readFile(footprint_path), "") if str.length(content) == 0 return 0 end if mut lines: [string] = new [string](max_count) let line_count = str.split(content, '\n', lines, max_count) mut count = 0 mut i = 0 while i < line_count and count < max_count let line = str.trim_ws(lines[i]) if str.length(line) == 0 i = i + 1 continue end if if str.equals(line, "./.PKGINFO") or str.equals(line, "./.FOOTPRINT") i = i + 1 continue end if if str.equals(line, ".PKGINFO") or str.equals(line, ".FOOTPRINT") i = i + 1 continue end if let len = str.length(line) if len > 0 and line[len - 1] == '/' i = i + 1 continue end if if str.starts_with(line, "./") files[count] = str.substring(line, 2, len - 2) else files[count] = line end if count = count + 1 i = i + 1 end while return count end read_footprint // Read manifest entries from .MANIFEST in an extracted package directory fn read_manifest(pkg_dir: string, entries: [mtree.ManifestEntry], max_count: int): int let manifest_path = path.join_path(pkg_dir, ".MANIFEST") if not file.fileExists(manifest_path) return 0 end if let content = res.unwrap_or(file.readFile(manifest_path), "") if str.length(content) == 0 return 0 end if return mtree.parse_manifest(content, entries, max_count) end read_manifest // Install files from extracted package to root directory using manifest fn install_files(src_dir: string, root: string): bool // Read config file list for protection checks mut config_files: [string] = new [string](256) let config_count = read_config_files(src_dir, config_files, 256) // Parse .MANIFEST mut entries: [mtree.ManifestEntry] = new [mtree.ManifestEntry](8192) let entry_count = read_manifest(src_dir, entries, 8192) if entry_count == 0 return false end if // Walk manifest entries in order (dirs before their contents) mut i = 0 while i < entry_count let entry = entries[i] let etype = mtree.entry_type(entry) let epath = mtree.entry_path(entry) // Strip leading ./ from manifest path to get relative path mut rel_path = epath if str.starts_with(epath, "./") rel_path = str.substring(epath, 2, str.length(epath) - 2) end if let dest_path = path.join_path(root, rel_path) let src_path = path.join_path(src_dir, rel_path) let mode = mtree.entry_mode(entry) let uid = mtree.name_to_uid(mtree.entry_uname(entry)) let gid = mtree.name_to_gid(mtree.entry_gname(entry)) if str.equals(etype, "dir") // Create directory if it doesn't exist if not dir.dir_exists(dest_path) if not res.is_ok(dir.create_dir_all(dest_path)) return false end if end if perm.chmod(dest_path, mode) perm.chown(dest_path, uid, gid) elif str.equals(etype, "file") // Check if this is a config-protected file if is_config_file(rel_path, config_files, config_count) // Config file — use checksum-based protection let manifest_sha = mtree.entry_sha256(entry) install_config_file(src_path, dest_path, manifest_sha, "") else // Ensure parent directory exists let parent = path.dirname(dest_path) if str.length(parent) > 0 and not dir.dir_exists(parent) dir.create_dir_all(parent) end if // Remove any conflicting target (symlink, file, or dir) if stat.is_symlink(dest_path) ops.remove_file(dest_path) elif stat.exists(dest_path) ops.remove_file(dest_path) end if // Copy file preserving permissions from source if not res.is_ok(ops.copy_file_preserve(src_path, dest_path)) return false end if // Set permissions and ownership from manifest perm.chmod(dest_path, mode) perm.chown(dest_path, uid, gid) // Verify sha256 after copy let manifest_sha = mtree.entry_sha256(entry) if str.length(manifest_sha) > 0 let installed_sha = checksum.sha256_file(dest_path) if not str.equals(installed_sha, manifest_sha) return false end if end if end if elif str.equals(etype, "link") let link_target = mtree.entry_link(entry) // Remove any conflicting target if stat.is_symlink(dest_path) or stat.exists(dest_path) ops.remove_file(dest_path) end if // Ensure parent directory exists let parent = path.dirname(dest_path) if str.length(parent) > 0 and not dir.dir_exists(parent) dir.create_dir_all(parent) end if // Create symlink if not res.is_ok(link.symlink(link_target, dest_path)) return false end if end if i = i + 1 end while return true end install_files // Remove files from root directory based on flat file list (legacy) fn remove_files(files: [string], file_count: int, root: string): bool // Remove files in reverse order mut i = file_count - 1 while i >= 0 let rel_path = files[i] let full_path = path.join_path(root, rel_path) if stat.is_symlink(full_path) or stat.exists(full_path) ops.remove_file(full_path) end if i = i - 1 end while // Try to remove empty parent directories i = file_count - 1 while i >= 0 let rel_path = files[i] let full_path = path.join_path(root, rel_path) let parent = path.dirname(full_path) if str.length(parent) > 0 and dir.dir_exists(parent) dir.remove_dir(parent) end if i = i - 1 end while return true end remove_files // Remove files using manifest entries (with explicit directory tracking) // Processes in reverse order: files/symlinks first, then directories fn remove_files_manifest(entries: [mtree.ManifestEntry], entry_count: int, root: string): bool mut all_success = true // First pass: remove files and symlinks in reverse order mut i = entry_count - 1 while i >= 0 let entry = entries[i] let etype = mtree.entry_type(entry) let epath = mtree.entry_path(entry) mut rel_path = epath if str.starts_with(epath, "./") rel_path = str.substring(epath, 2, str.length(epath) - 2) end if let full_path = path.join_path(root, rel_path) if str.equals(etype, "file") or str.equals(etype, "link") if stat.is_symlink(full_path) or stat.exists(full_path) if not res.is_ok(ops.remove_file(full_path)) all_success = false end if end if end if i = i - 1 end while // Second pass: remove directories in reverse order (deepest first) i = entry_count - 1 while i >= 0 let entry = entries[i] let etype = mtree.entry_type(entry) let epath = mtree.entry_path(entry) if str.equals(etype, "dir") mut rel_path = epath if str.starts_with(epath, "./") rel_path = str.substring(epath, 2, str.length(epath) - 2) end if let full_path = path.join_path(root, rel_path) // Only remove empty directories (remove_dir fails on non-empty) if dir.dir_exists(full_path) dir.remove_dir(full_path) end if end if i = i - 1 end while return all_success end remove_files_manifest // Verify package has required files fn verify_package(pkg_dir: string): bool // Check for .PKGINFO let pkginfo_path = path.join_path(pkg_dir, ".PKGINFO") if not file.fileExists(pkginfo_path) return false end if // Check for .MANIFEST (or legacy .FOOTPRINT) let manifest_path = path.join_path(pkg_dir, ".MANIFEST") let footprint_path = path.join_path(pkg_dir, ".FOOTPRINT") if not file.fileExists(manifest_path) and not file.fileExists(footprint_path) return false end if // Verify we can read package info let info = read_pkginfo(pkg_dir) if str.length(info.name) == 0 return false end if return true end verify_package // Check if package has install/remove scripts fn has_scripts(pkg_dir: string): bool let scripts_dir = path.join_path(pkg_dir, ".SCRIPTS") return dir.dir_exists(scripts_dir) end has_scripts // Context describing where a package's maintainer scripts run. // root == "/" -> normal install on the live system // do_chroot -> confine scripts to root via chroot (root must be != "/") type ScriptCtx = struct root: string do_chroot: bool end ScriptCtx // Construct a ScriptCtx. fn new_script_ctx(root: string, do_chroot: bool): ScriptCtx return ScriptCtx{ root: root, do_chroot: do_chroot } end new_script_ctx // Pure predicate: should scripts be chrooted for this root + config? fn script_wants_chroot(root: string, chroot_scripts: bool): bool if str.equals(root, "/") return false end if return chroot_scripts end script_wants_chroot // Resolve the script execution context for a command flow. // want_chroot requires both a non-"/" root with chroot enabled AND a root caller; // the caller is responsible for aborting first when chroot is wanted but caller_is_root // is false (see resolve in the command flows). fn resolve_script_ctx(root: string, chroot_scripts: bool, caller_is_root: bool): ScriptCtx mut want_chroot = script_wants_chroot(root, chroot_scripts) if not caller_is_root want_chroot = false end if return new_script_ctx(root, want_chroot) end resolve_script_ctx // The single place a maintainer-script shell command is assembled. // is_sh true -> run under zsh; false -> execute the file directly. fn build_script_cmd(ctx: ScriptCtx, script_path: string, script_name: string, args: string, is_sh: bool): string mut cmd = "" if ctx.do_chroot // Confined: run inside ; from the script's view the target is "/". let in_root_path = "/.SCRIPTS/" + script_name mut body = "" if is_sh body = "zsh \"" + in_root_path + "\"" else body = "\"" + in_root_path + "\"" end if cmd = "chroot \"" + ctx.root + "\" env CORAL_ROOT=\"/\" " + body else // Host: normal install or --no-chroot-scripts. Export CORAL_ROOT=ctx.root. mut body = "" if is_sh body = "zsh \"" + script_path + "\"" else body = "\"" + script_path + "\"" end if cmd = "CORAL_ROOT=\"" + ctx.root + "\" " + body end if if str.length(args) > 0 cmd = cmd + " " + args end if return cmd end build_script_cmd // Copy a package's .SCRIPTS dir into /.SCRIPTS so it is reachable post-chroot. fn stage_scripts_in_root(pkg_scripts_dir: string, root: string): bool let dest = path.join_path(root, ".SCRIPTS") // Clean any stale staging first. let rm_cmd = "rm -rf \"" + dest + "\"" let rm_pid = process.process_spawn_shell(rm_cmd) process.process_wait(rm_pid) // Copy preserving perms/owner/symlinks. let cp_cmd = "cp -a \"" + pkg_scripts_dir + "\" \"" + dest + "\"" let pid = process.process_spawn_shell(cp_cmd) if pid < 0 return false end if let code = process.process_wait(pid) return code == 0 end stage_scripts_in_root // Remove staged scripts from /.SCRIPTS. proc unstage_scripts_in_root(root: string) let dest = path.join_path(root, ".SCRIPTS") let cmd = "rm -rf \"" + dest + "\"" let pid = process.process_spawn_shell(cmd) process.process_wait(pid) end unstage_scripts_in_root // Run a script from the .SCRIPTS directory fn run_script(ctx: ScriptCtx, pkg_dir: string, script_name: string, script_args: string): bool let scripts_dir = path.join_path(pkg_dir, ".SCRIPTS") let script_path = path.join_path(scripts_dir, script_name) if not file.fileExists(script_path) return true end if if ctx.do_chroot if not stage_scripts_in_root(scripts_dir, ctx.root) // Staging may have left a partial /.SCRIPTS; clean it before bailing. unstage_scripts_in_root(ctx.root) return false end if end if let is_sh = str.ends_with(script_name, ".sh") let cmd = build_script_cmd(ctx, script_path, script_name, script_args, is_sh) let pid = process.process_spawn_shell(cmd) mut ok = false if pid >= 0 let exit_code = process.process_wait(pid) ok = exit_code == 0 end if if ctx.do_chroot unstage_scripts_in_root(ctx.root) end if return ok end run_script // Run pre-install script (passes $VERSION) fn run_pre_install(ctx: ScriptCtx, pkg_dir: string, version: string): bool if not has_scripts(pkg_dir) return true end if let args = "\"" + version + "\"" let scripts_dir = path.join_path(pkg_dir, ".SCRIPTS") // Try .sh first, then bare executable let sh_path = path.join_path(scripts_dir, "pre-install.sh") if file.fileExists(sh_path) return run_script(ctx, pkg_dir, "pre-install.sh", args) end if return run_script(ctx, pkg_dir, "pre-install", args) end run_pre_install // Run post-install script (passes $VERSION) fn run_post_install(ctx: ScriptCtx, pkg_dir: string, version: string): bool if not has_scripts(pkg_dir) return true end if let args = "\"" + version + "\"" let scripts_dir = path.join_path(pkg_dir, ".SCRIPTS") let sh_path = path.join_path(scripts_dir, "post-install.sh") if file.fileExists(sh_path) return run_script(ctx, pkg_dir, "post-install.sh", args) end if return run_script(ctx, pkg_dir, "post-install", args) end run_post_install // Run pre-remove script (passes $VERSION) fn run_pre_remove(ctx: ScriptCtx, pkg_dir: string, version: string): bool if not has_scripts(pkg_dir) return true end if let args = "\"" + version + "\"" let scripts_dir = path.join_path(pkg_dir, ".SCRIPTS") let sh_path = path.join_path(scripts_dir, "pre-remove.sh") if file.fileExists(sh_path) return run_script(ctx, pkg_dir, "pre-remove.sh", args) end if return run_script(ctx, pkg_dir, "pre-remove", args) end run_pre_remove // Run post-remove script (passes $VERSION) fn run_post_remove(ctx: ScriptCtx, pkg_dir: string, version: string): bool if not has_scripts(pkg_dir) return true end if let args = "\"" + version + "\"" let scripts_dir = path.join_path(pkg_dir, ".SCRIPTS") let sh_path = path.join_path(scripts_dir, "post-remove.sh") if file.fileExists(sh_path) return run_script(ctx, pkg_dir, "post-remove.sh", args) end if return run_script(ctx, pkg_dir, "post-remove", args) end run_post_remove // Run pre-upgrade script (passes $OLD_VERSION $NEW_VERSION) fn run_pre_upgrade(ctx: ScriptCtx, pkg_dir: string, old_version: string, new_version: string): bool if not has_scripts(pkg_dir) return true end if let args = "\"" + old_version + "\" \"" + new_version + "\"" let scripts_dir = path.join_path(pkg_dir, ".SCRIPTS") let sh_path = path.join_path(scripts_dir, "pre-upgrade.sh") if file.fileExists(sh_path) return run_script(ctx, pkg_dir, "pre-upgrade.sh", args) end if return run_script(ctx, pkg_dir, "pre-upgrade", args) end run_pre_upgrade // Run post-upgrade script (passes $OLD_VERSION $NEW_VERSION) fn run_post_upgrade(ctx: ScriptCtx, pkg_dir: string, old_version: string, new_version: string): bool if not has_scripts(pkg_dir) return true end if let args = "\"" + old_version + "\" \"" + new_version + "\"" let scripts_dir = path.join_path(pkg_dir, ".SCRIPTS") let sh_path = path.join_path(scripts_dir, "post-upgrade.sh") if file.fileExists(sh_path) return run_script(ctx, pkg_dir, "post-upgrade.sh", args) end if return run_script(ctx, pkg_dir, "post-upgrade", args) end run_post_upgrade // Run a script directly from a stored scripts directory fn run_stored_script(ctx: ScriptCtx, scripts_dir: string, script_name: string, script_args: string): bool let script_path = path.join_path(scripts_dir, script_name) if not file.fileExists(script_path) return true end if if ctx.do_chroot if not stage_scripts_in_root(scripts_dir, ctx.root) // Staging may have left a partial /.SCRIPTS; clean it before bailing. unstage_scripts_in_root(ctx.root) return false end if end if let is_sh = str.ends_with(script_name, ".sh") let cmd = build_script_cmd(ctx, script_path, script_name, script_args, is_sh) let pid = process.process_spawn_shell(cmd) mut ok = false if pid >= 0 let exit_code = process.process_wait(pid) ok = exit_code == 0 end if if ctx.do_chroot unstage_scripts_in_root(ctx.root) end if return ok end run_stored_script // Run pre-remove script from stored scripts directory (passes $VERSION) fn run_stored_pre_remove(ctx: ScriptCtx, scripts_dir: string, version: string): bool if not dir.dir_exists(scripts_dir) return true end if let args = "\"" + version + "\"" let sh_path = path.join_path(scripts_dir, "pre-remove.sh") if file.fileExists(sh_path) return run_stored_script(ctx, scripts_dir, "pre-remove.sh", args) end if return run_stored_script(ctx, scripts_dir, "pre-remove", args) end run_stored_pre_remove // Run post-remove script from stored scripts directory (passes $VERSION) fn run_stored_post_remove(ctx: ScriptCtx, scripts_dir: string, version: string): bool if not dir.dir_exists(scripts_dir) return true end if let args = "\"" + version + "\"" let sh_path = path.join_path(scripts_dir, "post-remove.sh") if file.fileExists(sh_path) return run_stored_script(ctx, scripts_dir, "post-remove.sh", args) end if return run_stored_script(ctx, scripts_dir, "post-remove", args) end run_stored_post_remove // Run pre-upgrade script from stored scripts directory (passes $OLD_VERSION $NEW_VERSION) fn run_stored_pre_upgrade(ctx: ScriptCtx, scripts_dir: string, old_version: string, new_version: string): bool if not dir.dir_exists(scripts_dir) return true end if let args = "\"" + old_version + "\" \"" + new_version + "\"" let sh_path = path.join_path(scripts_dir, "pre-upgrade.sh") if file.fileExists(sh_path) return run_stored_script(ctx, scripts_dir, "pre-upgrade.sh", args) end if return run_stored_script(ctx, scripts_dir, "pre-upgrade", args) end run_stored_pre_upgrade // Run post-upgrade script from stored scripts directory (passes $OLD_VERSION $NEW_VERSION) fn run_stored_post_upgrade(ctx: ScriptCtx, scripts_dir: string, old_version: string, new_version: string): bool if not dir.dir_exists(scripts_dir) return true end if let args = "\"" + old_version + "\" \"" + new_version + "\"" let sh_path = path.join_path(scripts_dir, "post-upgrade.sh") if file.fileExists(sh_path) return run_stored_script(ctx, scripts_dir, "post-upgrade.sh", args) end if return run_stored_script(ctx, scripts_dir, "post-upgrade", args) end run_stored_post_upgrade // Read list of config files from .CONFIG file in extracted package fn read_config_files(pkg_dir: string, config_files: [string], max_count: int): int let config_path = path.join_path(pkg_dir, ".CONFIG") if not file.fileExists(config_path) return 0 end if let content = res.unwrap_or(file.readFile(config_path), "") if str.length(content) == 0 return 0 end if // Split by newlines mut lines: [string] = new [string](max_count) let line_count = str.split(content, '\n', lines, max_count) // Filter out empty lines mut count = 0 mut i = 0 while i < line_count and count < max_count let line = str.trim_ws(lines[i]) if str.length(line) > 0 and line[0] != '#' // Normalize path (remove leading ./) if str.starts_with(line, "./") config_files[count] = str.substring(line, 2, str.length(line) - 2) else config_files[count] = line end if count = count + 1 end if i = i + 1 end while return count end read_config_files // Check if a file path is in the config files list fn is_config_file(rel_path: string, config_files: [string], config_count: int): bool mut i = 0 while i < config_count if str.equals(rel_path, config_files[i]) return true end if i = i + 1 end while return false end is_config_file // Install a config file with checksum-based protection. // manifest_sha256: checksum of the new package's config file // stored_sha256: checksum from the previously installed package version (empty on fresh install) fn install_config_file(src_path: string, dest_path: string, manifest_sha256: string, stored_sha256: string): bool if not stat.exists(dest_path) // Fresh install — file doesn't exist yet // Create parent directory if needed let dest_dir = path.dirname(dest_path) if str.length(dest_dir) > 0 and not dir.dir_exists(dest_dir) dir.create_dir_all(dest_dir) end if return res.is_ok(ops.copy_file_preserve(src_path, dest_path)) end if // File exists — check if user has modified it if str.length(stored_sha256) == 0 // No stored checksum (fresh install over existing file or legacy package) // Be safe: install as .pkg-new, preserve user's file let new_path = dest_path + ".pkg-new" return res.is_ok(ops.copy_file_preserve(src_path, new_path)) end if let installed_sha = checksum.sha256_file(dest_path) if str.equals(installed_sha, stored_sha256) // User has not modified the config — safe to overwrite return res.is_ok(ops.copy_file_preserve(src_path, dest_path)) else // User has modified the config — preserve their version let new_path = dest_path + ".pkg-new" return res.is_ok(ops.copy_file_preserve(src_path, new_path)) end if end install_config_file // Look up sha256 for a path in manifest entries fn find_manifest_sha256(rel_path: string, entries: [mtree.ManifestEntry], count: int): string mut i = 0 while i < count let epath = mtree.entry_path(entries[i]) mut entry_rel = epath if str.starts_with(epath, "./") entry_rel = str.substring(epath, 2, str.length(epath) - 2) end if if str.equals(entry_rel, rel_path) return mtree.entry_sha256(entries[i]) end if i = i + 1 end while return "" end find_manifest_sha256 // Install files from extracted package with old manifest for upgrade config protection fn install_files_upgrade(src_dir: string, root: string, old_entries: [mtree.ManifestEntry], old_entry_count: int): bool mut config_files: [string] = new [string](256) let config_count = read_config_files(src_dir, config_files, 256) mut entries: [mtree.ManifestEntry] = new [mtree.ManifestEntry](8192) let entry_count = read_manifest(src_dir, entries, 8192) if entry_count == 0 return false end if mut i = 0 while i < entry_count let entry = entries[i] let etype = mtree.entry_type(entry) let epath = mtree.entry_path(entry) mut rel_path = epath if str.starts_with(epath, "./") rel_path = str.substring(epath, 2, str.length(epath) - 2) end if let dest_path = path.join_path(root, rel_path) let src_path = path.join_path(src_dir, rel_path) let mode = mtree.entry_mode(entry) let uid = mtree.name_to_uid(mtree.entry_uname(entry)) let gid = mtree.name_to_gid(mtree.entry_gname(entry)) if str.equals(etype, "dir") if not dir.dir_exists(dest_path) if not res.is_ok(dir.create_dir_all(dest_path)) return false end if end if perm.chmod(dest_path, mode) perm.chown(dest_path, uid, gid) elif str.equals(etype, "file") if is_config_file(rel_path, config_files, config_count) let manifest_sha = mtree.entry_sha256(entry) // Look up old sha256 from previous version's manifest let stored_sha = find_manifest_sha256(rel_path, old_entries, old_entry_count) install_config_file(src_path, dest_path, manifest_sha, stored_sha) else let parent = path.dirname(dest_path) if str.length(parent) > 0 and not dir.dir_exists(parent) dir.create_dir_all(parent) end if if stat.is_symlink(dest_path) ops.remove_file(dest_path) elif stat.exists(dest_path) ops.remove_file(dest_path) end if if not res.is_ok(ops.copy_file_preserve(src_path, dest_path)) return false end if perm.chmod(dest_path, mode) perm.chown(dest_path, uid, gid) let manifest_sha = mtree.entry_sha256(entry) if str.length(manifest_sha) > 0 let installed_sha = checksum.sha256_file(dest_path) if not str.equals(installed_sha, manifest_sha) return false end if end if end if elif str.equals(etype, "link") let link_target = mtree.entry_link(entry) if stat.is_symlink(dest_path) or stat.exists(dest_path) ops.remove_file(dest_path) end if let parent = path.dirname(dest_path) if str.length(parent) > 0 and not dir.dir_exists(parent) dir.create_dir_all(parent) end if if not res.is_ok(link.symlink(link_target, dest_path)) return false end if end if i = i + 1 end while return true end install_files_upgrade // Parse a TOML inline array string like ["foo", "bar"] into a string array fn parse_toml_array(value: string, result: [string], max_items: int): int let len = str.length(value) if len < 2 return 0 end if if value[0] != '[' if len > 0 result[0] = value return 1 end if return 0 end if mut pos = 1 mut count = 0 while pos < len and count < max_items while pos < len and (value[pos] == ' ' or value[pos] == '\t' or value[pos] == '\n') pos = pos + 1 end while if pos >= len or value[pos] == ']' break end if if value[pos] == '"' pos = pos + 1 mut item_start = pos while pos < len and value[pos] != '"' pos = pos + 1 end while if pos > item_start result[count] = str.substring(value, item_start, pos - item_start) count = count + 1 end if pos = pos + 1 elif value[pos] != ']' and value[pos] != ',' mut item_start = pos while pos < len and value[pos] != ',' and value[pos] != ']' and value[pos] != ' ' pos = pos + 1 end while if pos > item_start result[count] = str.substring(value, item_start, pos - item_start) count = count + 1 end if end if while pos < len and (value[pos] == ',' or value[pos] == ' ' or value[pos] == '\t' or value[pos] == '\n') pos = pos + 1 end while end while return count end parse_toml_array end module