# SPDX-License-Identifier: LSL-1.0 # Copyright (c) 2026 Markus Maiwald # Stewardship: Self Sovereign Society Foundation # # This file is part of the Nexus Sovereign Core. # See legal/LICENSE_SOVEREIGN.md for license terms. ## nimpak/filesystem.nim ## GoboLinux-style filesystem management with generation integration ## ## This module implements the filesystem operations for NimPak, including: ## - GoboLinux-style /Programs/App/Version directory structure ## - Atomic symlink management in /System/Index ## - Generation-aware filesystem operations ## - Boot integration for generation selection import std/[os, strutils, times, json, tables, sequtils, algorithm, osproc] import ./types_fixed type FilesystemError* = object of types_fixed.NimPakError path*: string EnhancedFilesystemManager* = object programsRoot*: string ## /Programs - Package installation directory indexRoot*: string ## /System/Index - Symlink directory generationsRoot*: string ## /System/Generations - Generation metadata currentGeneration*: string ## Current active generation ID dryRun*: bool ## Dry run mode for testing GenerationFilesystem* = object generation*: Generation symlinkMap*: Table[string, string] ## target -> source mapping backupPath*: string ## Backup location for rollback # ============================================================================= # FilesystemManager Creation and Configuration # ============================================================================= proc newEnhancedFilesystemManager*(programsRoot: string = "/Programs", indexRoot: string = "/System/Index", generationsRoot: string = "/System/Generations", dryRun: bool = false): EnhancedFilesystemManager = ## Create a new EnhancedFilesystemManager with specified paths EnhancedFilesystemManager( programsRoot: programsRoot, indexRoot: indexRoot, generationsRoot: generationsRoot, currentGeneration: "", # Will be loaded from filesystem dryRun: dryRun ) proc loadCurrentGeneration*(fm: var EnhancedFilesystemManager): Result[void, FilesystemError] = ## Load the current generation ID from filesystem try: let currentGenFile = fm.generationsRoot / "current" if fileExists(currentGenFile): fm.currentGeneration = readFile(currentGenFile).strip() else: # No current generation - this is a fresh system fm.currentGeneration = "" return ok[void, FilesystemError]() except IOError as e: return err[void, FilesystemError](FilesystemError( code: FileReadError, msg: "Failed to load current generation: " & e.msg, path: fm.generationsRoot / "current" )) # ============================================================================= # Package Installation with Generation Integration # ============================================================================= proc installPackage*(fm: EnhancedFilesystemManager, pkg: Fragment, generation: Generation): Result[InstallLocation, FilesystemError] = ## Install a package to the filesystem with generation tracking try: let programDir = fm.programsRoot / pkg.id.name / pkg.id.version # Create program directory structure if not fm.dryRun: createDir(programDir) createDir(programDir / "bin") createDir(programDir / "lib") createDir(programDir / "share") createDir(programDir / "etc") # Generate symlinks for this package var indexLinks: seq[SymlinkPair] = @[] # Scan for binaries to symlink let binDir = programDir / "bin" if dirExists(binDir): for file in walkDir(binDir): if file.kind == pcFile: let fileName = extractFilename(file.path) indexLinks.add(SymlinkPair( source: file.path, target: fm.indexRoot / "bin" / fileName )) # Scan for libraries to symlink let libDir = programDir / "lib" if dirExists(libDir): for file in walkDir(libDir): if file.kind == pcFile and (file.path.endsWith(".so") or file.path.contains(".so.")): let fileName = extractFilename(file.path) indexLinks.add(SymlinkPair( source: file.path, target: fm.indexRoot / "lib" / fileName )) # Scan for shared data to symlink let shareDir = programDir / "share" if dirExists(shareDir): for subdir in walkDir(shareDir): if subdir.kind == pcDir: let dirName = extractFilename(subdir.path) indexLinks.add(SymlinkPair( source: subdir.path, target: fm.indexRoot / "share" / dirName )) let location = InstallLocation( programDir: programDir, indexLinks: indexLinks ) return ok[InstallLocation, FilesystemError](location) except OSError as e: return err[InstallLocation, FilesystemError](FilesystemError( code: FileWriteError, msg: "Failed to install package: " & e.msg, path: fm.programsRoot / pkg.id.name / pkg.id.version )) # ============================================================================= # Atomic Symlink Management # ============================================================================= proc createSymlinks*(fm: EnhancedFilesystemManager, location: InstallLocation, generation: Generation): Result[void, FilesystemError] = ## Create symlinks for package installation with generation tracking try: for link in location.indexLinks: let targetDir = parentDir(link.target) if not fm.dryRun: # Ensure target directory exists if not dirExists(targetDir): createDir(targetDir) # Create the symlink (remove existing if present) if symlinkExists(link.target) or fileExists(link.target): removeFile(link.target) createSymlink(link.source, link.target) else: echo "DRY RUN: Would create symlink " & link.source & " -> " & link.target return ok[void, FilesystemError]() except OSError as e: return err[void, FilesystemError](FilesystemError( code: FileWriteError, msg: "Failed to create symlinks: " & e.msg, path: location.programDir )) proc atomicSymlinkUpdate*(fm: EnhancedFilesystemManager, updates: seq[SymlinkPair], generation: Generation): Result[void, FilesystemError] = ## Atomically update symlinks with generation tracking and rollback capability try: # Create backup of current symlink state let backupDir = fm.generationsRoot / generation.id / "symlink_backup" if not fm.dryRun: createDir(backupDir) var backupData: seq[SymlinkPair] = @[] # Phase 1: Backup existing symlinks for update in updates: if symlinkExists(update.target): let currentTarget = expandSymlink(update.target) backupData.add(SymlinkPair( source: currentTarget, target: update.target )) if not fm.dryRun: # Save backup information let backupFile = backupDir / extractFilename(update.target) & ".backup" writeFile(backupFile, currentTarget) elif fileExists(update.target): # Handle regular files that need to be replaced let backupFile = backupDir / extractFilename(update.target) & ".file" if not fm.dryRun: copyFile(update.target, backupFile) # Phase 2: Apply new symlinks atomically for update in updates: let targetDir = parentDir(update.target) if not fm.dryRun: # Ensure target directory exists if not dirExists(targetDir): createDir(targetDir) # Remove existing file/symlink if fileExists(update.target) or symlinkExists(update.target): removeFile(update.target) # Create new symlink createSymlink(update.source, update.target) else: echo "DRY RUN: Would update symlink " & update.source & " -> " & update.target # Phase 3: Record generation symlink state if not fm.dryRun: let symlinkStateFile = fm.generationsRoot / generation.id / "symlinks.json" let symlinkState = %*{ "generation": generation.id, "timestamp": $generation.timestamp, "symlinks": updates.mapIt(%*{ "source": it.source, "target": it.target }), "backup_location": backupDir } writeFile(symlinkStateFile, $symlinkState) return ok[void, FilesystemError]() except OSError as e: # Attempt rollback on failure discard rollbackSymlinks(fm, backupData) return err[void, FilesystemError](FilesystemError( code: FileWriteError, msg: "Failed to update symlinks atomically: " & e.msg, path: fm.indexRoot )) proc rollbackSymlinks*(fm: EnhancedFilesystemManager, backupData: seq[SymlinkPair]): Result[void, FilesystemError] = ## Rollback symlinks to previous state try: for backup in backupData: if not fm.dryRun: # Remove current symlink if fileExists(backup.target) or symlinkExists(backup.target): removeFile(backup.target) # Restore original symlink createSymlink(backup.source, backup.target) else: echo "DRY RUN: Would rollback symlink " & backup.source & " -> " & backup.target return ok[void, FilesystemError]() except OSError as e: return err[void, FilesystemError](FilesystemError( code: FileWriteError, msg: "Failed to rollback symlinks: " & e.msg, path: fm.indexRoot )) # ============================================================================= # Generation Switching and Management # ============================================================================= proc switchToGeneration*(fm: var EnhancedFilesystemManager, targetGeneration: Generation): Result[void, FilesystemError] = ## Switch the system to a specific generation atomically try: let generationDir = fm.generationsRoot / targetGeneration.id let symlinkStateFile = generationDir / "symlinks.json" if not fileExists(symlinkStateFile): return err[void, FilesystemError](FilesystemError( code: PackageNotFound, msg: "Generation symlink state not found: " & targetGeneration.id, path: symlinkStateFile )) # Load generation symlink state let symlinkStateJson = parseJson(readFile(symlinkStateFile)) var targetSymlinks: seq[SymlinkPair] = @[] for linkJson in symlinkStateJson["symlinks"].getElems(): targetSymlinks.add(SymlinkPair( source: linkJson["source"].getStr(), target: linkJson["target"].getStr() )) # Perform atomic symlink update to target generation let updateResult = atomicSymlinkUpdate(fm, targetSymlinks, targetGeneration) if updateResult.isErr: return updateResult # Update current generation pointer if not fm.dryRun: let currentGenFile = fm.generationsRoot / "current" writeFile(currentGenFile, targetGeneration.id) fm.currentGeneration = targetGeneration.id return ok[void, FilesystemError]() except JsonParsingError as e: return err[void, FilesystemError](FilesystemError( code: InvalidMetadata, msg: "Failed to parse generation metadata: " & e.msg, path: fm.generationsRoot / targetGeneration.id )) except IOError as e: return err[void, FilesystemError](FilesystemError( code: FileReadError, msg: "Failed to switch generation: " & e.msg, path: fm.generationsRoot / targetGeneration.id )) proc rollbackToPreviousGeneration*(fm: var EnhancedFilesystemManager): Result[Generation, FilesystemError] = ## Rollback to the previous generation try: if fm.currentGeneration.len == 0: return err[Generation, FilesystemError](FilesystemError( code: PackageNotFound, msg: "No current generation to rollback from", path: fm.generationsRoot )) # Load current generation metadata let currentGenFile = fm.generationsRoot / fm.currentGeneration / "generation.json" if not fileExists(currentGenFile): return err[Generation, FilesystemError](FilesystemError( code: PackageNotFound, msg: "Current generation metadata not found", path: currentGenFile )) let currentGenJson = parseJson(readFile(currentGenFile)) let previousGenId = currentGenJson.getOrDefault("previous") if previousGenId.isNil or previousGenId.getStr().len == 0: return err[Generation, FilesystemError](FilesystemError( code: PackageNotFound, msg: "No previous generation available for rollback", path: currentGenFile )) # Load previous generation let previousGenFile = fm.generationsRoot / previousGenId.getStr() / "generation.json" let previousGenJson = parseJson(readFile(previousGenFile)) let previousGeneration = Generation( id: previousGenId.getStr(), timestamp: previousGenJson["timestamp"].getStr().parseTime("yyyy-MM-dd'T'HH:mm:ss'.'fff'Z'", utc()), packages: previousGenJson["packages"].getElems().mapIt(PackageId( name: it["name"].getStr(), version: it["version"].getStr(), stream: parseEnum[PackageStream](it["stream"].getStr()) )), previous: if previousGenJson.hasKey("previous") and not previousGenJson["previous"].isNil: some(previousGenJson["previous"].getStr()) else: none(string), size: previousGenJson["size"].getInt() ) # Switch to previous generation let switchResult = switchToGeneration(fm, previousGeneration) if switchResult.isErr: return err[Generation, FilesystemError](switchResult.getError()) return ok[Generation, FilesystemError](previousGeneration) except JsonParsingError as e: return err[Generation, FilesystemError](FilesystemError( code: InvalidMetadata, msg: "Failed to parse generation metadata: " & e.msg, path: fm.generationsRoot )) except IOError as e: return err[Generation, FilesystemError](FilesystemError( code: FileReadError, msg: "Failed to rollback generation: " & e.msg, path: fm.generationsRoot )) # ============================================================================= # Generation Export and Import # ============================================================================= proc exportGeneration*(fm: EnhancedFilesystemManager, generation: Generation, exportPath: string): Result[void, FilesystemError] = ## Export a generation for system migration try: let generationDir = fm.generationsRoot / generation.id if not dirExists(generationDir): return err[void, FilesystemError](FilesystemError( code: PackageNotFound, msg: "Generation directory not found: " & generation.id, path: generationDir )) # Create export archive let exportCmd = "tar -czf " & exportPath & " -C " & generationDir & " ." let result = execProcess(exportCmd, options = {poUsePath}) if result.exitCode != 0: return err[void, FilesystemError](FilesystemError( code: FileWriteError, msg: "Failed to export generation: " & result.output, path: exportPath )) return ok[void, FilesystemError]() except OSError as e: return err[void, FilesystemError](FilesystemError( code: FileWriteError, msg: "Failed to export generation: " & e.msg, path: exportPath )) proc importGeneration*(fm: EnhancedFilesystemManager, importPath: string, newGenerationId: string): Result[Generation, FilesystemError] = ## Import a generation from archive try: let generationDir = fm.generationsRoot / newGenerationId if dirExists(generationDir): return err[Generation, FilesystemError](FilesystemError( code: FileWriteError, msg: "Generation already exists: " & newGenerationId, path: generationDir )) # Create generation directory if not fm.dryRun: createDir(generationDir) # Extract archive let extractCmd = "tar -xzf " & importPath & " -C " & generationDir let result = execProcess(extractCmd, options = {poUsePath}) if result.exitCode != 0: return err[Generation, FilesystemError](FilesystemError( code: FileReadError, msg: "Failed to import generation: " & result.output, path: importPath )) # Load generation metadata let generationFile = generationDir / "generation.json" let generationJson = parseJson(readFile(generationFile)) let importedGeneration = Generation( id: newGenerationId, # Use new ID timestamp: now(), # Update timestamp packages: generationJson["packages"].getElems().mapIt(PackageId( name: it["name"].getStr(), version: it["version"].getStr(), stream: parseEnum[PackageStream](it["stream"].getStr()) )), previous: some(fm.currentGeneration), # Link to current generation size: generationJson["size"].getInt() ) return ok[Generation, FilesystemError](importedGeneration) except JsonParsingError as e: return err[Generation, FilesystemError](FilesystemError( code: InvalidMetadata, msg: "Failed to parse imported generation: " & e.msg, path: importPath )) except IOError as e: return err[Generation, FilesystemError](FilesystemError( code: FileReadError, msg: "Failed to import generation: " & e.msg, path: importPath )) # ============================================================================= # Generation Repair and Recovery # ============================================================================= proc repairGeneration*(fm: EnhancedFilesystemManager, generation: Generation): Result[void, FilesystemError] = ## Repair a corrupted generation by rebuilding symlinks try: let generationDir = fm.generationsRoot / generation.id let symlinkStateFile = generationDir / "symlinks.json" if not fileExists(symlinkStateFile): return err[void, FilesystemError](FilesystemError( code: PackageNotFound, msg: "Generation symlink state not found for repair: " & generation.id, path: symlinkStateFile )) # Load expected symlink state let symlinkStateJson = parseJson(readFile(symlinkStateFile)) var expectedSymlinks: seq[SymlinkPair] = @[] for linkJson in symlinkStateJson["symlinks"].getElems(): expectedSymlinks.add(SymlinkPair( source: linkJson["source"].getStr(), target: linkJson["target"].getStr() )) # Verify and repair each symlink var repairedCount = 0 for expected in expectedSymlinks: let needsRepair = if symlinkExists(expected.target): expandSymlink(expected.target) != expected.source else: true # Missing symlink needs repair if needsRepair: if not fm.dryRun: # Remove incorrect symlink/file if fileExists(expected.target) or symlinkExists(expected.target): removeFile(expected.target) # Ensure target directory exists let targetDir = parentDir(expected.target) if not dirExists(targetDir): createDir(targetDir) # Create correct symlink createSymlink(expected.source, expected.target) else: echo "DRY RUN: Would repair symlink " & expected.source & " -> " & expected.target repairedCount += 1 if repairedCount > 0: echo "Repaired " & $repairedCount & " symlinks in generation " & generation.id else: echo "Generation " & generation.id & " is healthy - no repairs needed" return ok[void, FilesystemError]() except JsonParsingError as e: return err[void, FilesystemError](FilesystemError( code: InvalidMetadata, msg: "Failed to parse generation metadata for repair: " & e.msg, path: generationDir )) except OSError as e: return err[void, FilesystemError](FilesystemError( code: FileWriteError, msg: "Failed to repair generation: " & e.msg, path: generationDir )) # ============================================================================= # Boot Integration Support # ============================================================================= proc createBootEntry*(fm: EnhancedFilesystemManager, generation: Generation, bootDir: string = "/boot"): Result[void, FilesystemError] = ## Create boot entry for generation selection try: let bootEntryDir = bootDir / "nexus" / "generations" if not fm.dryRun: createDir(bootEntryDir) let bootEntryFile = bootEntryDir / generation.id & ".conf" let bootEntry = """ title NexusOS Generation """ & generation.id & """ version """ & generation.id & """ linux /nexus/kernel initrd /nexus/initrd options nexus.generation=""" & generation.id & """ root=LABEL=nexus-root """ if not fm.dryRun: writeFile(bootEntryFile, bootEntry) else: echo "DRY RUN: Would create boot entry " & bootEntryFile return ok[void, FilesystemError]() except IOError as e: return err[void, FilesystemError](FilesystemError( code: FileWriteError, msg: "Failed to create boot entry: " & e.msg, path: bootDir )) proc setDefaultBootGeneration*(fm: EnhancedFilesystemManager, generation: Generation, bootDir: string = "/boot"): Result[void, FilesystemError] = ## Set the default boot generation try: let defaultBootFile = bootDir / "nexus" / "default_generation" if not fm.dryRun: writeFile(defaultBootFile, generation.id) else: echo "DRY RUN: Would set default boot generation to " & generation.id return ok[void, FilesystemError]() except IOError as e: return err[void, FilesystemError](FilesystemError( code: FileWriteError, msg: "Failed to set default boot generation: " & e.msg, path: bootDir )) # ============================================================================= # Utility Functions # ============================================================================= proc getGenerationFilesystemInfo*(fm: EnhancedFilesystemManager, generation: Generation): Result[GenerationFilesystem, FilesystemError] = ## Get detailed filesystem information for a generation try: let generationDir = fm.generationsRoot / generation.id let symlinkStateFile = generationDir / "symlinks.json" if not fileExists(symlinkStateFile): return err[GenerationFilesystem, FilesystemError](FilesystemError( code: PackageNotFound, msg: "Generation filesystem state not found: " & generation.id, path: symlinkStateFile )) let symlinkStateJson = parseJson(readFile(symlinkStateFile)) var symlinkMap: Table[string, string] = initTable[string, string]() for linkJson in symlinkStateJson["symlinks"].getElems(): let target = linkJson["target"].getStr() let source = linkJson["source"].getStr() symlinkMap[target] = source let backupPath = symlinkStateJson.getOrDefault("backup_location") let backupLocation = if backupPath.isNil: "" else: backupPath.getStr() let genFs = GenerationFilesystem( generation: generation, symlinkMap: symlinkMap, backupPath: backupLocation ) return ok[GenerationFilesystem, FilesystemError](genFs) except JsonParsingError as e: return err[GenerationFilesystem, FilesystemError](FilesystemError( code: InvalidMetadata, msg: "Failed to parse generation filesystem info: " & e.msg, path: generationDir )) except IOError as e: return err[GenerationFilesystem, FilesystemError](FilesystemError( code: FileReadError, msg: "Failed to load generation filesystem info: " & e.msg, path: generationDir ))