/****************************************************************************** __ ____ __ / / ___ ____ _/ __/_____________ _/ /__ / / / _ \/ __ `/ /_/ ___/ ___/ __ `/ / _ \ / /___/ __/ /_/ / __(__ ) /__/ /_/ / / __/ /_____/\___/\__,_/_/ /____/\___/\__,_/_/\___/ (C)opyright 2025, Leafscale, LLC - https://www.leafscale.com Project: Zygaena Filename: mtree.reef Authors: Chris Tusa License: Description: mtree-format manifest for package file inventory ******************************************************************************/ module util.mtree import io.file import io.dir import io.path import core.str import fs.stat import fs.link import crypto.sha256 as sha import util.checksum import text.stringbuilder as sb export // Manifest entry representing a single file, directory, or symlink type ManifestEntry // Parse an mtree manifest string into an array of entries // Returns the number of entries parsed fn parse_manifest(content: string, entries: [ManifestEntry], max_entries: int): int // Generate an mtree manifest string from a package staging directory fn generate_manifest(pkg_dir: string): string // Accessors fn entry_path(entry: ManifestEntry): string fn entry_type(entry: ManifestEntry): string fn entry_mode(entry: ManifestEntry): int fn entry_uname(entry: ManifestEntry): string fn entry_gname(entry: ManifestEntry): string fn entry_sha256(entry: ManifestEntry): string fn entry_link(entry: ManifestEntry): string // Format a ManifestEntry as an mtree line fn format_entry(entry: ManifestEntry): string // Uid/gid name resolution helpers fn uid_to_name(uid: int): string fn gid_to_name(gid: int): string fn name_to_uid(name: string): int fn name_to_gid(name: string): int end export // ============================================================================ // Types // ============================================================================ type ManifestEntry = struct epath: string // Relative path (e.g., ./usr/bin/foo) etype: string // "file", "dir", or "link" mode: int // Permission bits as integer (e.g., 0755 = 493) uname: string // Owner user name gname: string // Owner group name sha256: string // SHA256 checksum (files only) link_target: string // Symlink target (links only) end ManifestEntry // ============================================================================ // Accessors // ============================================================================ fn entry_path(entry: ManifestEntry): string return entry.epath end entry_path fn entry_type(entry: ManifestEntry): string return entry.etype end entry_type fn entry_mode(entry: ManifestEntry): int return entry.mode end entry_mode fn entry_uname(entry: ManifestEntry): string return entry.uname end entry_uname fn entry_gname(entry: ManifestEntry): string return entry.gname end entry_gname fn entry_sha256(entry: ManifestEntry): string return entry.sha256 end entry_sha256 fn entry_link(entry: ManifestEntry): string return entry.link_target end entry_link // ============================================================================ // Parser // ============================================================================ // Parse an mtree manifest string into entries. // Format per line: ./path type=T mode=OOOO uname=U gname=G [sha256=H] [link=T] // Lines starting with # are comments. Empty lines are skipped. fn parse_manifest(content: string, entries: [ManifestEntry], max_entries: int): int if str.length(content) == 0 return 0 end if // Split into lines mut lines: [string] = new [string](max_entries + 64) let line_count = str.split(content, '\n', lines, max_entries + 64) mut count = 0 mut i = 0 while i < line_count and count < max_entries let line = str.trim_ws(lines[i]) let line_len = str.length(line) // Skip empty lines and comments if line_len == 0 i = i + 1 continue end if if line[0] == '#' i = i + 1 continue end if // Parse the line: first token is path, rest are key=value let entry = parse_entry_line(line) if str.length(entry.epath) > 0 entries[count] = entry count = count + 1 end if i = i + 1 end while return count end parse_manifest // Parse a single mtree line into a ManifestEntry fn parse_entry_line(line: string): ManifestEntry mut entry = ManifestEntry{ epath: "", etype: "", mode: 0, uname: "root", gname: "root", sha256: "", link_target: "" } // Split line by spaces mut tokens: [string] = new [string](16) let token_count = str.split(line, ' ', tokens, 16) if token_count == 0 return entry end if // First token is always the path entry.epath = tokens[0] // Remaining tokens are key=value pairs mut t = 1 while t < token_count let token = tokens[t] let eq_pos = str.index_of_char(token, '=') if eq_pos > 0 let key = str.substring(token, 0, eq_pos) let val = str.substring(token, eq_pos + 1, str.length(token) - eq_pos - 1) if str.equals(key, "type") entry.etype = val elif str.equals(key, "mode") entry.mode = octal_to_int(val) elif str.equals(key, "uname") entry.uname = val elif str.equals(key, "gname") entry.gname = val elif str.equals(key, "sha256") entry.sha256 = val elif str.equals(key, "link") entry.link_target = val end if end if t = t + 1 end while return entry end parse_entry_line // ============================================================================ // Generator // ============================================================================ // Generate an mtree manifest for all files in a package staging directory. // Walks the directory tree, collects entries, sorts them, and returns the // formatted manifest string. fn generate_manifest(pkg_dir: string): string // Collect entries by walking the directory tree mut entries: [ManifestEntry] = new [ManifestEntry](8192) let count = walk_directory(pkg_dir, pkg_dir, entries, 0, 8192) if count == 0 return "" end if // Sort entries by path (simple insertion sort — fine for package file counts) sort_entries(entries, count) // Build the output string using StringBuilder to avoid O(n²) heap // fragmentation from repeated string concatenation (Reef BUG-027) let builder = sb.sb_new() sb.sb_append(builder, "#mtree\n") mut i = 0 while i < count sb.sb_append(builder, format_entry(entries[i])) sb.sb_append(builder, "\n") i = i + 1 end while return sb.sb_build(builder) end generate_manifest // Recursively walk a directory, collecting ManifestEntry records. // base_dir is the package root (for computing relative paths). // current_dir is the directory being walked. // Returns total number of entries added (starting from offset). fn walk_directory(base_dir: string, current_dir: string, entries: [ManifestEntry], offset: int, max: int): int mut count = offset // Add the current directory itself (unless it's the base) if not str.equals(current_dir, base_dir) and count < max let rel = get_relative_path(base_dir, current_dir) if not is_metadata_path(rel) let mode = stat.file_mode(current_dir) let uid = stat.file_uid(current_dir) let gid = stat.file_gid(current_dir) entries[count] = ManifestEntry{ epath: "./" + rel, etype: "dir", mode: mode, uname: uid_to_name(uid), gname: gid_to_name(gid), sha256: "", link_target: "" } count = count + 1 end if end if // List directory contents mut dir_entries: [string] = new [string](4096) let entry_count = dir.list_dir(current_dir, dir_entries, 4096) mut i = 0 while i < entry_count and count < max let name = dir_entries[i] // Skip . and .. to prevent infinite recursion if str.equals(name, ".") or str.equals(name, "..") i = i + 1 continue end if let full_path = path.join_path(current_dir, name) let rel = get_relative_path(base_dir, full_path) // Skip metadata files and directories if is_metadata_path(rel) i = i + 1 continue end if // Check type: symlink first (symlinks to dirs would match is_directory) if stat.is_symlink(full_path) let target = link.readlink(full_path) let uid = stat.file_uid(current_dir) let gid = stat.file_gid(current_dir) entries[count] = ManifestEntry{ epath: "./" + rel, etype: "link", mode: 0, uname: uid_to_name(uid), gname: gid_to_name(gid), sha256: "", link_target: target } count = count + 1 elif stat.is_directory(full_path) // Recurse into subdirectory count = walk_directory(base_dir, full_path, entries, count, max) elif stat.is_file(full_path) let mode = stat.file_mode(full_path) let uid = stat.file_uid(full_path) let gid = stat.file_gid(full_path) let hash = checksum.sha256_file(full_path) entries[count] = ManifestEntry{ epath: "./" + rel, etype: "file", mode: mode, uname: uid_to_name(uid), gname: gid_to_name(gid), sha256: hash, link_target: "" } count = count + 1 end if i = i + 1 end while return count end walk_directory // Check if a relative path is a package metadata file/directory fn is_metadata_path(rel: string): bool if str.equals(rel, ".PKGINFO") or str.equals(rel, ".MANIFEST") return true end if if str.equals(rel, ".FOOTPRINT") or str.equals(rel, ".CONFIG") return true end if if str.equals(rel, ".SCRIPTS") return true end if if str.starts_with(rel, ".SCRIPTS/") return true end if return false end is_metadata_path // Get relative path by stripping the base directory prefix fn get_relative_path(base: string, full: string): string let base_len = str.length(base) let full_len = str.length(full) if full_len <= base_len return "" end if // Skip the base prefix and the trailing / mut start = base_len if start < full_len and full[start] == '/' start = start + 1 end if return str.substring(full, start, full_len - start) end get_relative_path // ============================================================================ // Formatting // ============================================================================ // Format a ManifestEntry as a single mtree line fn format_entry(entry: ManifestEntry): string mut line = entry.epath + " type=" + entry.etype if str.equals(entry.etype, "file") or str.equals(entry.etype, "dir") line = line + " mode=" + int_to_octal(entry.mode) end if line = line + " uname=" + entry.uname line = line + " gname=" + entry.gname if str.equals(entry.etype, "file") and str.length(entry.sha256) > 0 line = line + " sha256=" + entry.sha256 end if if str.equals(entry.etype, "link") and str.length(entry.link_target) > 0 line = line + " link=" + entry.link_target end if return line end format_entry // ============================================================================ // Sorting // ============================================================================ // Insertion sort entries by path (lexicographic). // Package file counts are typically <1000, so insertion sort is adequate. proc sort_entries(entries: [ManifestEntry], count: int) mut i = 1 while i < count let key_entry = entries[i] let key_path = entries[i].epath mut j = i - 1 while j >= 0 and str.compare(entries[j].epath, key_path) > 0 entries[j + 1] = entries[j] j = j - 1 end while entries[j + 1] = key_entry i = i + 1 end while end sort_entries // ============================================================================ // Numeric Conversion Helpers // ============================================================================ // Convert an octal string (e.g., "0755", "4755") to an integer fn octal_to_int(s: string): int let slen = str.length(s) if slen == 0 return 0 end if mut result = 0 mut i = 0 while i < slen let c = s[i] if c >= '0' and c <= '7' result = result * 8 + (c - '0') end if i = i + 1 end while return result end octal_to_int // Convert an integer to a 4-digit octal string (e.g., 493 -> "0755") fn int_to_octal(n: int): string if n == 0 return "0000" end if mut value = n mut digits: [int] = new [int](16) mut count = 0 while value > 0 and count < 16 digits[count] = value - (value / 8 * 8) value = value / 8 count = count + 1 end while // Pad to 4 digits minimum while count < 4 digits[count] = 0 count = count + 1 end while // Build string in reverse (most significant first) mut result = "" mut i = count - 1 while i >= 0 result = result + digit_char(digits[i]) i = i - 1 end while return result end int_to_octal // Single digit to string character fn digit_char(d: int): string if d == 0 return "0" elif d == 1 return "1" elif d == 2 return "2" elif d == 3 return "3" elif d == 4 return "4" elif d == 5 return "5" elif d == 6 return "6" elif d == 7 return "7" end if return "0" end digit_char // Convert int to decimal string (local helper to avoid circular imports) fn int_to_str(n: int): string if n == 0 return "0" end if mut negative = false mut value = n if n < 0 negative = true value = 0 - n end if mut result = "" while value > 0 let digit = value - (value / 10 * 10) result = str.concat(digit_char(digit), result) value = value / 10 end while if negative result = str.concat("-", result) end if return result end int_to_str // ============================================================================ // UID/GID Name Resolution // ============================================================================ // Resolve a numeric uid to a username. // Uses POSIX getpwuid() via Reef's fs.stat.uid_name() — zero heap allocation. fn uid_to_name(uid: int): string return stat.uid_name(uid) end uid_to_name // Resolve a numeric gid to a group name. // Uses POSIX getgrgid() via Reef's fs.stat.gid_name() — zero heap allocation. fn gid_to_name(gid: int): string return stat.gid_name(gid) end gid_to_name // Resolve a username to numeric uid by reading /etc/passwd. // Only used during verify/install (low call count), not the hot manifest walk. fn name_to_uid(name: string): int if str.equals(name, "root") return 0 end if let content = file.readFile("/etc/passwd") if str.length(content) == 0 return 0 end if mut lines: [string] = new [string](256) let line_count = str.split(content, '\n', lines, 256) mut i = 0 while i < line_count let line = lines[i] if str.length(line) > 0 mut fields: [string] = new [string](8) let field_count = str.split(line, ':', fields, 8) if field_count >= 3 and str.equals(fields[0], name) return str_to_int(fields[2]) end if end if i = i + 1 end while return 0 end name_to_uid // Resolve a group name to numeric gid by reading /etc/group. // Only used during verify/install (low call count), not the hot manifest walk. fn name_to_gid(name: string): int if str.equals(name, "root") return 0 end if let content = file.readFile("/etc/group") if str.length(content) == 0 return 0 end if mut lines: [string] = new [string](256) let line_count = str.split(content, '\n', lines, 256) mut i = 0 while i < line_count let line = lines[i] if str.length(line) > 0 mut fields: [string] = new [string](8) let field_count = str.split(line, ':', fields, 8) if field_count >= 3 and str.equals(fields[0], name) return str_to_int(fields[2]) end if end if i = i + 1 end while return 0 end name_to_gid // Simple string-to-int conversion fn str_to_int(s: string): int let slen = str.length(s) if slen == 0 return 0 end if mut result = 0 mut i = 0 while i < slen let c = s[i] if c >= '0' and c <= '9' result = result * 10 + (c - '0') end if i = i + 1 end while return result end str_to_int end module