497 lines
19 KiB
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) |