nip/src/nimpak/recipes.nim

497 lines
19 KiB
Nim

# 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.
## NPR Recipe Format Handler (.npr)
##
## This module implements the NPR (Nexus Package Recipe) format for source-level
## package definitions. NPR files are plain-text KDL format files that are
## Git-friendly and contain build instructions, dependencies, and metadata.
##
## Format: .npr (Nexus Package Recipe, plain text KDL)
## - Human-readable KDL format for version control
## - Build instruction templates and dependency specifications
## - Ed25519 digital signatures for recipe integrity
## - Integration with build system and dependency resolution
import std/[os, json, times, strutils, sequtils, tables, options]
import ./types_fixed
import ./formats
import ./cas
type
NprError* = object of NimPakError
recipeName*: string
RecipeValidationResult* = object
valid*: bool
errors*: seq[ValidationError]
warnings*: seq[string]
# =============================================================================
# NPR Recipe Creation and Management
# =============================================================================
proc createNprRecipe*(metadata: Fragment, buildInstructions: BuildTemplate): NprRecipe =
## Factory method to create NPR recipe with proper defaults
NprRecipe(
metadata: metadata,
buildInstructions: buildInstructions,
signature: none(Signature),
format: NprRecipe,
cryptoAlgorithms: CryptoAlgorithms(
hashAlgorithm: "BLAKE2b",
signatureAlgorithm: "Ed25519",
version: "1.0"
)
)
proc createBuildTemplate*(system: BuildSystemType,
configureArgs: seq[string] = @[],
buildArgs: seq[string] = @[],
installArgs: seq[string] = @[],
environment: Table[string, string] = initTable[string, string]()): BuildTemplate =
## Factory method to create build template with sensible defaults
BuildTemplate(
system: system,
configureArgs: configureArgs,
buildArgs: buildArgs,
installArgs: installArgs,
environment: environment
)
# =============================================================================
# KDL Serialization for NPR Format
# =============================================================================
proc escapeKdlString(s: string): string =
## Escape special characters in KDL strings
result = "\""
for c in s:
case c:
of '"': result.add("\\\"")
of '\\': result.add("\\\\")
of '\n': result.add("\\n")
of '\r': result.add("\\r")
of '\t': result.add("\\t")
else: result.add(c)
result.add("\"")
proc formatKdlBoolean(b: bool): string =
## Format boolean for KDL
if b: "true" else: "false"
proc formatKdlArray(items: seq[string]): string =
## Format string array for KDL
if items.len == 0:
return ""
result = ""
for i, item in items:
if i > 0: result.add(" ")
result.add(escapeKdlString(item))
proc formatKdlTable(table: Table[string, string]): string =
## Format table as KDL key-value pairs
result = ""
for key, value in table:
result.add(" " & escapeKdlString(key) & " " & escapeKdlString(value) & "\n")
proc toHex(b: byte): string =
## Convert byte to hex string
const hexChars = "0123456789abcdef"
result = $hexChars[b shr 4] & $hexChars[b and 0x0F]
proc serializeNprToKdl*(recipe: NprRecipe): string =
## Serialize NPR recipe to KDL format with comprehensive metadata
## Plain-text format optimized for Git version control and human readability
result = "recipe " & escapeKdlString(recipe.metadata.id.name) & " {\n"
result.add(" version " & escapeKdlString(recipe.metadata.id.version) & "\n")
result.add(" stream " & escapeKdlString($recipe.metadata.id.stream) & "\n")
result.add(" format " & escapeKdlString($recipe.format) & "\n")
result.add("\n")
# Source information for fetching and building
result.add(" source {\n")
result.add(" method " & escapeKdlString($recipe.metadata.source.sourceMethod) & "\n")
result.add(" url " & escapeKdlString(recipe.metadata.source.url) & "\n")
result.add(" hash " & escapeKdlString(recipe.metadata.source.hash) & "\n")
result.add(" hash-algorithm " & escapeKdlString(recipe.metadata.source.hashAlgorithm) & "\n")
result.add(" timestamp " & escapeKdlString($recipe.metadata.source.timestamp) & "\n")
result.add(" }\n\n")
# Build system configuration and instructions
result.add(" build {\n")
result.add(" system " & escapeKdlString($recipe.buildInstructions.system) & "\n")
if recipe.buildInstructions.configureArgs.len > 0:
result.add(" configure-args " & formatKdlArray(recipe.buildInstructions.configureArgs) & "\n")
if recipe.buildInstructions.buildArgs.len > 0:
result.add(" build-args " & formatKdlArray(recipe.buildInstructions.buildArgs) & "\n")
if recipe.buildInstructions.installArgs.len > 0:
result.add(" install-args " & formatKdlArray(recipe.buildInstructions.installArgs) & "\n")
if recipe.buildInstructions.environment.len > 0:
result.add(" environment {\n")
result.add(formatKdlTable(recipe.buildInstructions.environment))
result.add(" }\n")
result.add(" }\n\n")
# Package metadata
result.add(" metadata {\n")
result.add(" description " & escapeKdlString(recipe.metadata.metadata.description) & "\n")
result.add(" license " & escapeKdlString(recipe.metadata.metadata.license) & "\n")
result.add(" maintainer " & escapeKdlString(recipe.metadata.metadata.maintainer) & "\n")
if recipe.metadata.metadata.tags.len > 0:
result.add(" tags " & formatKdlArray(recipe.metadata.metadata.tags) & "\n")
result.add(" }\n\n")
# Runtime profile requirements
result.add(" runtime {\n")
result.add(" libc " & escapeKdlString($recipe.metadata.metadata.runtime.libc) & "\n")
result.add(" allocator " & escapeKdlString($recipe.metadata.metadata.runtime.allocator) & "\n")
result.add(" systemd-aware " & formatKdlBoolean(recipe.metadata.metadata.runtime.systemdAware) & "\n")
result.add(" reproducible " & formatKdlBoolean(recipe.metadata.metadata.runtime.reproducible) & "\n")
if recipe.metadata.metadata.runtime.tags.len > 0:
result.add(" tags " & formatKdlArray(recipe.metadata.metadata.runtime.tags) & "\n")
result.add(" }\n\n")
# Dependencies with version constraints
if recipe.metadata.dependencies.len > 0:
result.add(" dependencies {\n")
for dep in recipe.metadata.dependencies:
result.add(" " & escapeKdlString(dep.name) & " " & escapeKdlString(dep.version) & " stream=" & escapeKdlString($dep.stream) & "\n")
result.add(" }\n\n")
# ACUL compliance requirements
result.add(" acul {\n")
result.add(" required " & formatKdlBoolean(recipe.metadata.acul.required) & "\n")
if recipe.metadata.acul.membership.len > 0:
result.add(" membership " & escapeKdlString(recipe.metadata.acul.membership) & "\n")
if recipe.metadata.acul.attribution.len > 0:
result.add(" attribution " & escapeKdlString(recipe.metadata.acul.attribution) & "\n")
if recipe.metadata.acul.buildLog.len > 0:
result.add(" build-log " & escapeKdlString(recipe.metadata.acul.buildLog) & "\n")
result.add(" }\n\n")
# Cryptographic integrity and signature
result.add(" integrity {\n")
result.add(" algorithm " & escapeKdlString(recipe.cryptoAlgorithms.hashAlgorithm) & "\n")
result.add(" signature-algorithm " & escapeKdlString(recipe.cryptoAlgorithms.signatureAlgorithm) & "\n")
result.add(" version " & escapeKdlString(recipe.cryptoAlgorithms.version) & "\n")
if recipe.signature.isSome:
let sig = recipe.signature.get()
result.add(" signature " & escapeKdlString(sig.signature.mapIt(it.toHex()).join("")) & "\n")
result.add(" key-id " & escapeKdlString(sig.keyId) & "\n")
result.add(" }\n")
result.add("}\n")
proc deserializeNprFromKdl*(kdlContent: string): Result[NprRecipe, NprError] =
## Deserialize NPR recipe from KDL format
## TODO: Implement proper KDL parsing when kdl library is available
## For now, return an error indicating this is not yet implemented
return err[NprRecipe, NprError](NprError(
code: InvalidMetadata,
msg: "KDL deserialization not yet implemented - waiting for kdl library",
recipeName: "unknown"
))
# =============================================================================
# Recipe Validation
# =============================================================================
proc validateNprRecipe*(recipe: NprRecipe): RecipeValidationResult =
## Validate NPR recipe format and content
var result = RecipeValidationResult(valid: true, errors: @[], warnings: @[])
# Validate basic metadata
if recipe.metadata.id.name.len == 0:
result.errors.add(ValidationError(
field: "metadata.id.name",
message: "Recipe name cannot be empty",
suggestions: @["Provide a valid recipe name"]
))
result.valid = false
if recipe.metadata.id.version.len == 0:
result.errors.add(ValidationError(
field: "metadata.id.version",
message: "Recipe version cannot be empty",
suggestions: @["Provide a valid version string"]
))
result.valid = false
# Validate source information
if recipe.metadata.source.url.len == 0:
result.errors.add(ValidationError(
field: "metadata.source.url",
message: "Source URL cannot be empty",
suggestions: @["Provide a valid source URL"]
))
result.valid = false
if recipe.metadata.source.hash.len == 0:
result.errors.add(ValidationError(
field: "metadata.source.hash",
message: "Source hash cannot be empty",
suggestions: @["Calculate and provide source hash"]
))
result.valid = false
# Validate build system configuration
if recipe.buildInstructions.system == Custom and recipe.buildInstructions.configureArgs.len == 0:
result.warnings.add("Custom build system without configure arguments may require manual intervention")
# Validate build system specific requirements
case recipe.buildInstructions.system:
of CMake:
if "CMAKE_BUILD_TYPE" notin recipe.buildInstructions.environment:
result.warnings.add("CMake build without CMAKE_BUILD_TYPE may use debug configuration")
of Autotools:
if recipe.buildInstructions.configureArgs.len == 0:
result.warnings.add("Autotools build without configure arguments may use default configuration")
of Cargo:
if "CARGO_BUILD_TARGET" notin recipe.buildInstructions.environment:
result.warnings.add("Cargo build without explicit target may not be reproducible")
else:
discard
# Validate dependencies
for i, dep in recipe.metadata.dependencies:
if dep.name.len == 0:
result.errors.add(ValidationError(
field: "dependencies[" & $i & "].name",
message: "Dependency name cannot be empty",
suggestions: @["Provide valid dependency name"]
))
result.valid = false
if dep.version.len == 0:
result.errors.add(ValidationError(
field: "dependencies[" & $i & "].version",
message: "Dependency version cannot be empty",
suggestions: @["Provide valid dependency version"]
))
result.valid = false
# Validate cryptographic algorithms
if not isQuantumResistant(recipe.cryptoAlgorithms):
result.warnings.add("Using non-quantum-resistant algorithms: " &
recipe.cryptoAlgorithms.hashAlgorithm & "/" &
recipe.cryptoAlgorithms.signatureAlgorithm)
return result
# =============================================================================
# Recipe File Operations
# =============================================================================
proc saveNprRecipe*(recipe: NprRecipe, filePath: string): Result[void, NprError] =
## Save NPR recipe to file in KDL format
try:
let kdlContent = serializeNprToKdl(recipe)
# Ensure the file has the correct .npr extension
let finalPath = if filePath.endsWith(".npr"): filePath else: filePath & ".npr"
# Ensure parent directory exists
let parentDir = finalPath.parentDir()
if not dirExists(parentDir):
createDir(parentDir)
writeFile(finalPath, kdlContent)
return ok[void, NprError]()
except IOError as e:
return err[void, NprError](NprError(
code: FileWriteError,
msg: "Failed to save NPR recipe: " & e.msg,
recipeName: recipe.metadata.id.name
))
proc loadNprRecipe*(filePath: string): Result[NprRecipe, NprError] =
## Load NPR recipe from file
try:
if not fileExists(filePath):
return err[NprRecipe, NprError](NprError(
code: PackageNotFound,
msg: "NPR recipe file not found: " & filePath,
recipeName: "unknown"
))
let kdlContent = readFile(filePath)
return deserializeNprFromKdl(kdlContent)
except IOError as e:
return err[NprRecipe, NprError](NprError(
code: FileReadError,
msg: "Failed to load NPR recipe: " & e.msg,
recipeName: "unknown"
))
# =============================================================================
# Recipe Digital Signatures
# =============================================================================
proc signNprRecipe*(recipe: var NprRecipe, keyId: string, privateKey: seq[byte]): Result[void, NprError] =
## Sign NPR recipe with Ed25519 private key
## Creates a comprehensive signature payload including all critical recipe metadata
try:
# Create comprehensive signature payload from recipe metadata and build instructions
let payload = recipe.metadata.id.name &
recipe.metadata.id.version &
$recipe.metadata.id.stream &
recipe.metadata.source.hash &
$recipe.buildInstructions.system &
recipe.buildInstructions.configureArgs.join(" ") &
recipe.buildInstructions.buildArgs.join(" ") &
recipe.buildInstructions.installArgs.join(" ")
# TODO: Implement actual Ed25519 signing when crypto library is available
# For now, create a deterministic placeholder signature based on payload
let payloadHash = calculateBlake2b(payload.toOpenArrayByte(0, payload.len - 1).toSeq())
let placeholderSig = payloadHash[0..63].toOpenArrayByte(0, 63).toSeq() # 64 bytes like Ed25519
let signature = Signature(
keyId: keyId,
algorithm: recipe.cryptoAlgorithms.signatureAlgorithm,
signature: placeholderSig
)
recipe.signature = some(signature)
return ok[void, NprError]()
except Exception as e:
return err[void, NprError](NprError(
code: UnknownError,
msg: "Failed to sign recipe: " & e.msg,
recipeName: recipe.metadata.id.name
))
proc verifyNprSignature*(recipe: NprRecipe, publicKey: seq[byte]): Result[bool, NprError] =
## Verify NPR recipe signature
## TODO: Implement proper Ed25519 verification when crypto library is available
if recipe.signature.isNone:
return ok[bool, NprError](false) # No signature to verify
try:
let sig = recipe.signature.get()
# TODO: Implement actual Ed25519 verification
# For now, just check if signature exists and has correct length
let isValid = sig.signature.len == 64 and sig.keyId.len > 0
return ok[bool, NprError](isValid)
except Exception as e:
return err[bool, NprError](NprError(
code: UnknownError,
msg: "Failed to verify signature: " & e.msg,
recipeName: recipe.metadata.id.name
))
# =============================================================================
# Build System Integration
# =============================================================================
proc getBuildSystemDefaults*(system: BuildSystemType): BuildTemplate =
## Get default build template for a build system
case system:
of CMake:
return createBuildTemplate(
system = CMake,
configureArgs = @["-DCMAKE_BUILD_TYPE=Release"],
buildArgs = @["--build", ".", "--parallel"],
installArgs = @["--install", "."],
environment = {"CMAKE_BUILD_TYPE": "Release"}.toTable()
)
of Autotools:
return createBuildTemplate(
system = Autotools,
configureArgs = @["--prefix=/usr"],
buildArgs = @["-j$(nproc)"],
installArgs = @["install"],
environment = initTable[string, string]()
)
of Meson:
return createBuildTemplate(
system = Meson,
configureArgs = @["setup", "builddir", "--buildtype=release"],
buildArgs = @["-C", "builddir"],
installArgs = @["install", "-C", "builddir"],
environment = initTable[string, string]()
)
of Cargo:
return createBuildTemplate(
system = Cargo,
configureArgs = @[],
buildArgs = @["build", "--release"],
installArgs = @["install", "--path", "."],
environment = {"CARGO_BUILD_TARGET": "x86_64-unknown-linux-musl"}.toTable()
)
of NimBuild:
return createBuildTemplate(
system = NimBuild,
configureArgs = @[],
buildArgs = @["c", "-d:release", "--mm:orc"],
installArgs = @[],
environment = {"NIM_BUILD_TYPE": "release"}.toTable()
)
of Custom:
return createBuildTemplate(
system = Custom,
configureArgs = @[],
buildArgs = @[],
installArgs = @[],
environment = initTable[string, string]()
)
proc validateBuildInstructions*(instructions: BuildTemplate): seq[string] =
## Validate build instructions and return warnings
var warnings: seq[string] = @[]
case instructions.system:
of CMake:
if "-DCMAKE_BUILD_TYPE" notin instructions.configureArgs.join(" "):
warnings.add("CMake build without explicit build type")
of Autotools:
if "--prefix" notin instructions.configureArgs.join(" "):
warnings.add("Autotools build without explicit prefix")
of Cargo:
if "--release" notin instructions.buildArgs.join(" "):
warnings.add("Cargo build without release flag")
else:
discard
return warnings
# =============================================================================
# Utility Functions
# =============================================================================
proc getNprInfo*(recipe: NprRecipe): string =
## Get human-readable recipe information
result = "NPR Recipe: " & recipe.metadata.id.name & " v" & recipe.metadata.id.version & "\n"
result.add("Stream: " & $recipe.metadata.id.stream & "\n")
result.add("Build System: " & $recipe.buildInstructions.system & "\n")
result.add("Dependencies: " & $recipe.metadata.dependencies.len & "\n")
result.add("Source: " & recipe.metadata.source.url & "\n")
if recipe.signature.isSome:
result.add("Signed: Yes (Key: " & recipe.signature.get().keyId & ")\n")
else:
result.add("Signed: No\n")
proc calculateBlake2b*(data: seq[byte]): string =
## Calculate BLAKE2b hash - imported from CAS module
cas.calculateBlake2b(data)
proc calculateBlake3*(data: seq[byte]): string =
## Calculate BLAKE3 hash - imported from CAS module
cas.calculateBlake3(data)