packages feed

settei-preflight (empty) → 0.3.0.0

raw patch · 17 files changed

+1731/−0 lines, 17 filesdep +aesondep +basedep +bytestring

Dependencies added: aeson, base, bytestring, containers, directory, filepath, generic-lens, optparse-applicative, process, settei, settei-env, settei-preflight, tasty, tasty-hunit, temporary, text

Files

+ CHANGELOG.md view
@@ -0,0 +1,15 @@+# Changelog++## 0.3.0.0 — 2026-10-21++Initial release.++### New Features++- Add the reusable `Settei.Preflight` deployment preflight harness with explicit+  input classifications, controlled environment/file channels, timeout, cleanup,+  exact-value secret redaction, and deterministic text and JSON reports.+- Add strict reference-only secret requests through `Settei.Preflight.Request`+  and the `settei-preflight --request PATH [--json]` executable.+- Accept `aeson >=2.2.4.1 && <2.4` (tested on 2.2.4.1, 2.2.5.1, and 2.3.2.0), so the+  package also builds against the locked nixpkgs Haskell set.
+ LICENSE view
@@ -0,0 +1,28 @@+Copyright (c) 2026, shinzui+All rights reserved.++Redistribution and use in source and binary forms, with or without+modification, are permitted provided that the following conditions are met:++1. Redistributions of source code must retain the above copyright notice,+   this list of conditions and the following disclaimer.++2. Redistributions in binary form must reproduce the above copyright notice,+   this list of conditions and the following disclaimer in the documentation+   and/or other materials provided with the distribution.++3. Neither the name of the copyright holder nor the names of its contributors+   may be used to endorse or promote products derived from this software+   without specific prior written permission.++THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE+ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE+LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR+CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF+SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS+INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN+CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)+ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE+POSSIBILITY OF SUCH DAMAGE.
+ README.md view
@@ -0,0 +1,31 @@+# settei-preflight++New in the shared 0.3.0.0 family release; this package was not part of 0.2.0.0.++Run an application's own configuration check against explicitly proposed inputs.+`Settei.Preflight` isolates the environment, materializes files in a private directory,+redacts exact known secrets, and reports `pass`, `inconclusive`, or `fail`.+Only `pass` passes a gate; partial coverage is never deployment validation.+Reports have private constructors; use `preflightVerdict`, `preflightOutcome`,+`preflightCoverage`, `preflightExecution`, and the summary accessors to inspect them+without a lens dependency.+Supported execution platforms: Linux and macOS. The harness is not a sandbox.++`Settei.Preflight.Request` parses strict `settei.preflight-request` version 1 JSON.+Unknown fields and conflicting forms fail. Each environment/file entry has a name+and exactly one of `value` (public), `secretFromEnvironment`, `secretFromFile`,+`synthetic` with `reason` and optional `sensitivity`, `unresolved`, or `unavailableSecret`.+Secret files are read as strict UTF-8 without trimming. Request references are resolved+from the harness process; they never mean inheritance into the checked process.++Run `settei-preflight --request request.json [--json]`. Exit codes: 0 pass,+1 inconclusive, 2 CLI usage, 3 harness failure, 4 application rejection.+The child receives only proposed present inputs plus the explicit inherited allowlist+(default `PATH`, `LANG`, `LC_ALL`, `LC_CTYPE`; `inherit: []` disables it).+The timeout covers child completion and both output streams. SIGTERM-resistant children+and descendants are not force-killed. Temporary input directories use mode 0700;+failed cleanup blocks the gate. Output is captured in memory, so use a trusted,+bounded-output configuration check.++See [the deployment preflight guide](../docs/guides/deployment-preflight.md) in the+repository for the request schema, reference extraction workflow, and limitations.
+ app/Main.hs view
@@ -0,0 +1,37 @@+module Main (main) where++import Control.Exception (IOException, try)+import Data.ByteString qualified as BS+import Data.Generics.Labels ()+import Data.Text.IO qualified as T+import Options.Applicative qualified as O+import Settei.Preflight+import Settei.Preflight.Request+import Settei.Prelude+import System.Exit (ExitCode (..), exitWith)+import System.IO (hSetEncoding, stderr, stdout, utf8)++main :: IO ()+main = do+  mapM_ (`hSetEncoding` utf8) [stdout, stderr]+  (path, asJson) <-+    O.execParser $+      O.info+        ( (,)+            <$> O.parserOptionGroup "Preflight" (O.strOption (O.long "request" <> O.metavar "PATH" <> O.help "Read a preflight request document"))+            <*> O.parserOptionGroup "Output" (O.switch (O.long "json" <> O.help "Print the versioned JSON report")) O.<**> O.helper+        )+        (O.fullDesc <> O.progDesc "Check proposed inputs with the application's own check command" <> O.failureCode 2)+  readResult <- try (BS.readFile path) :: IO (Either IOException BS.ByteString)+  request <- case readResult of+    Left _ -> abort "request: file could not be read\n"+    Right bytes -> case parsePreflightRequest bytes of Left ps -> abort (renderRequestErrorsText ps); Right r -> pure r+  resolved <- resolveRequest processRequestEnvironment request+  report <- runPreflight (resolved ^. #options) (resolved ^. #role) (resolved ^. #command) (resolved ^. #inputs)+  if asJson then T.putStrLn (renderPreflightReportJson report) else T.putStr (renderPreflightReportText report)+  exitWith $ case report ^. #verdict of+    Pass -> ExitSuccess+    Inconclusive -> ExitFailure 1+    Fail -> ExitFailure (case report ^. #outcome of Rejected _ _ -> 4; _ -> 3)+  where+    abort message = T.hPutStr stderr message >> exitWith (ExitFailure 3)
+ settei-preflight.cabal view
@@ -0,0 +1,102 @@+cabal-version: 3.8+name: settei-preflight+version: 0.3.0.0+synopsis: Run application configuration checks on proposed deployment inputs+description:+  Materialize explicitly proposed environment and file inputs, run the application's+  own check command, redact known secrets, and report honest deployment coverage.++homepage: https://github.com/shinzui/settei+bug-reports: https://github.com/shinzui/settei/issues+license: BSD-3-Clause+license-file: LICENSE+author: shinzui+maintainer: shinzui+category: Configuration+build-type: Simple+tested-with: ghc ==9.12.4+extra-doc-files:+  CHANGELOG.md+  README.md++data-files:+  test/golden/*.json+  test/golden/*.txt++source-repository head+  type: git+  location: https://github.com/shinzui/settei.git++common common+  default-language: GHC2024+  default-extensions:+    DeriveAnyClass+    DuplicateRecordFields+    OverloadedLabels+    OverloadedStrings++  ghc-options:+    -Wall+    -Wcompat++common dependencies+  build-depends:+    aeson >=2.2.4.1 && <2.4,+    base >=4.21 && <5,+    bytestring >=0.12 && <0.13,+    containers >=0.6.8 && <0.8,+    directory >=1.3.8 && <1.4,+    filepath >=1.5.4 && <1.6,+    generic-lens >=2.2 && <2.4,+    process >=1.6.19 && <1.7,+    settei ==0.3.0.0,+    settei-env ==0.3.0.0,+    temporary >=1.3 && <1.4,+    text >=2.1 && <2.2,++library+  import: common, dependencies+  hs-source-dirs: src+  exposed-modules:+    Settei.Preflight+    Settei.Preflight.Request++  other-modules: Settei.Preflight.Internal++executable settei-preflight+  import: common+  hs-source-dirs: app+  main-is: Main.hs+  ghc-options: -threaded+  build-depends:+    base >=4.21 && <5,+    bytestring >=0.12 && <0.13,+    generic-lens >=2.2 && <2.4,+    optparse-applicative >=0.19 && <0.20,+    settei ==0.3.0.0,+    settei-preflight ==0.3.0.0,+    text >=2.1 && <2.2,++test-suite settei-preflight-tests+  import: common, dependencies+  type: exitcode-stdio-1.0+  hs-source-dirs:+    test+    src++  main-is: Main.hs+  ghc-options: -threaded+  other-modules:+    Paths_settei_preflight+    Settei.Preflight+    Settei.Preflight.Fixture+    Settei.Preflight.Internal+    Settei.Preflight.Request+    Settei.Preflight.RequestTest+    Settei.PreflightTest++  autogen-modules: Paths_settei_preflight+  build-tool-depends: settei-preflight:settei-preflight+  build-depends:+    tasty >=1.5 && <1.6,+    tasty-hunit >=0.10.2 && <0.11,
+ src/Settei/Preflight.hs view
@@ -0,0 +1,155 @@+-- | Run an application's configuration check against explicitly proposed inputs.+-- Only 'Pass' passes a deployment gate. This runner is not a sandbox and does not+-- establish connectivity, deployed image identity, or readiness. Known nonempty+-- secrets are redacted by exact match; transformed or unclassified secrets cannot+-- be recognized. Supported execution platforms are Linux and macOS.+module Settei.Preflight+  ( RoleLabel (..),+    FileName (..),+    ArgumentPart (..),+    Argument (..),+    literalArgument,+    fileArgument,+    CheckCommand (..),+    ProposedValue (..),+    ProposedFile (..),+    ProposedInputs (..),+    defaultInherited,+    emptyProposedInputs,+    ExitCodeMapping (..),+    defaultExitCodes,+    PreflightOptions (..),+    defaultPreflightOptions,+    PreflightProblem (..),+    InputRef (..),+    validatePreflight,+    renderPreflightProblemText,+    renderPreflightProblemsText,+    runPreflight,+    PreflightReport,+    CommandSummary,+    InputSummary,+    Classification (..),+    Outcome (..),+    RejectionCategory (..),+    NotRunReason (..),+    Coverage (..),+    Limitation,+    LimitationKind (..),+    Verdict (..),+    Execution,+    preflightRole,+    preflightCommand,+    preflightInputs,+    preflightInherited,+    preflightNotes,+    preflightOutcome,+    preflightCoverage,+    preflightVerdict,+    preflightExecution,+    commandExecutable,+    commandArguments,+    inputReference,+    inputClassification,+    inputPassed,+    limitationInput,+    limitationKind,+    limitationReason,+    executionExitCode,+    executionTimedOut,+    executionStandardOutput,+    executionStandardError,+    renderPreflightReportText,+    renderPreflightReportJson,+  )+where++import Data.Generics.Labels ()+import Settei.Env (EnvName)+import Settei.Preflight.Internal+import Settei.Prelude (Text, (^.))++-- | Read-only accessors keep reports usable without a lens dependency.+preflightRole :: PreflightReport -> RoleLabel+preflightRole value = value ^. #role++-- | The sanitized executable and symbolic command arguments.+preflightCommand :: PreflightReport -> CommandSummary+preflightCommand value = value ^. #command++-- | Input summaries ordered by channel and name.+preflightInputs :: PreflightReport -> [InputSummary]+preflightInputs value = value ^. #inputs++-- | Inherited execution-context names and whether each was present; never their values.+preflightInherited :: PreflightReport -> [(EnvName, Bool)]+preflightInherited value = value ^. #inherited++-- | Sanitized extraction notes; every version-one note reduces coverage.+preflightNotes :: PreflightReport -> [Text]+preflightNotes value = value ^. #notes++-- | Application acceptance/rejection or the harness failure reason.+preflightOutcome :: PreflightReport -> Outcome+preflightOutcome value = value ^. #outcome++-- | Whether every proposed input was supplied without coverage limitations.+preflightCoverage :: PreflightReport -> Coverage+preflightCoverage value = value ^. #coverage++-- | The gate decision. Only 'Pass' passes; 'Inconclusive' also blocks a gate.+preflightVerdict :: PreflightReport -> Verdict+preflightVerdict value = value ^. #verdict++-- | Captured execution details when a child was successfully spawned.+preflightExecution :: PreflightReport -> Maybe Execution+preflightExecution value = value ^. #execution++-- | The sanitized resolved executable path, or attempted path when lookup failed.+commandExecutable :: CommandSummary -> FilePath+commandExecutable value = value ^. #executable++-- | Sanitized symbolic arguments using file/directory references instead of temporary paths.+commandArguments :: CommandSummary -> [Text]+commandArguments value = value ^. #arguments++-- | The sanitized environment name or relative file name.+inputReference :: InputSummary -> InputRef+inputReference value = value ^. #input++-- | The input's supplied, synthetic, unresolved, or unavailable classification.+inputClassification :: InputSummary -> Classification+inputClassification value = value ^. #classification++-- | True only for a present value supplied to a successfully spawned child.+-- It does not establish that the application consumed the input.+inputPassed :: InputSummary -> Bool+inputPassed value = value ^. #passed++-- | The affected input or zero-based extraction-note index.+limitationInput :: Limitation -> InputRef+limitationInput value = value ^. #input++-- | The category preventing complete coverage.+limitationKind :: Limitation -> LimitationKind+limitationKind value = value ^. #kind++-- | The sanitized reason for incomplete coverage.+limitationReason :: Limitation -> Text+limitationReason value = value ^. #reason++-- | The child exit code, if captured before a timeout or harness failure.+executionExitCode :: Execution -> Maybe Int+executionExitCode value = value ^. #exitCode++-- | Whether the timeout expired during child completion or pipe draining.+executionTimedOut :: Execution -> Bool+executionTimedOut value = value ^. #timedOut++-- | Captured stdout with exact known nonempty secrets redacted.+executionStandardOutput :: Execution -> Text+executionStandardOutput value = value ^. #standardOutput++-- | Captured stderr with exact known nonempty secrets redacted.+executionStandardError :: Execution -> Text+executionStandardError value = value ^. #standardError
+ src/Settei/Preflight/Internal.hs view
@@ -0,0 +1,549 @@+-- | Private implementation and injectable IO boundary. Raw inputs deliberately lack Show.+module Settei.Preflight.Internal where++import Control.Concurrent (forkFinally, killThread, newChan, newEmptyMVar, putMVar, readChan, readMVar, writeChan)+import Control.Exception (IOException, bracket, fromException, throwIO, try)+import Control.Monad (forM_, unless, void, when)+import Data.Aeson qualified as A+import Data.Aeson.Encoding qualified as E+import Data.ByteString qualified as BS+import Data.ByteString.Lazy qualified as BL+import Data.Char (isAsciiLower, isAsciiUpper, isDigit)+import Data.Generics.Labels ()+import Data.IORef (modifyIORef', newIORef, readIORef)+import Data.List (find, sortOn)+import Data.List.NonEmpty qualified as NE+import Data.Text qualified as T+import Data.Text.Encoding qualified as TE+import Data.Text.Encoding.Error (lenientDecode)+import Settei (Sensitivity (..))+import Settei.Env (EnvName (..))+import Settei.Prelude+import System.Directory qualified as D+import System.Environment qualified as Env+import System.Exit (ExitCode (..))+import System.FilePath (takeDirectory, (</>))+import System.IO qualified as IO+import System.IO.Temp (withSystemTempDirectory)+import System.Process qualified as P+import System.Timeout (timeout)++-- | An operator-facing application role. Labels must be nonblank and credential-free.+newtype RoleLabel = RoleLabel Text deriving stock (Generic, Eq, Show)++-- | A path relative to the private input directory. Validation rejects absolute,+-- empty, dot, parent, NUL, and backslash path segments.+newtype FileName = FileName Text deriving stock (Generic, Eq, Ord, Show)++-- | A public literal or symbolic reference to a proposed file or directory.+-- References are replaced with private absolute paths when the command runs.+data ArgumentPart = LiteralPart !Text | FilePart !FileName | DirectoryPart !FileName deriving stock (Generic, Eq)++-- | One command-line argument assembled from ordered parts, without invoking a shell.+-- Never put credentials in literal parts; use environment or file channels.+newtype Argument = Argument [ArgumentPart] deriving stock (Generic, Eq)++-- | One public literal argument. Known secrets in assembled arguments are rejected.+literalArgument :: Text -> Argument+literalArgument t = Argument [LiteralPart t]++-- | One argument naming a declared proposed file in the private directory.+fileArgument :: FileName -> Argument+fileArgument f = Argument [FilePart f]++-- | The caller-selected host executable and arguments for an application check.+-- The runner cannot establish its equivalence to a deployed image or sandbox it.+data CheckCommand = CheckCommand {executable :: !FilePath, arguments :: ![Argument]} deriving stock (Generic, Eq)++-- | A supplied value, fake synthetic stand-in with reason, unresolved input, or+-- unavailable secret. Only supplied values count toward complete coverage.+-- Sensitivity controls exact-value redaction; raw values deliberately lack Show.+data ProposedValue = SuppliedValue !Sensitivity !Text | SyntheticValue !Sensitivity !Text !Text | UnresolvedValue !Text | UnavailableSecret !Text deriving stock (Generic, Eq)++-- | A named UTF-8 file input. Unresolved and unavailable contents are not written.+data ProposedFile = ProposedFile {name :: !FileName, content :: !ProposedValue} deriving stock (Generic, Eq)++-- | The complete proposal: ordered environment entries, UTF-8 files, an explicit+-- inherited-variable allowlist, and extraction notes. Every note reduces coverage.+-- The child otherwise starts with an empty environment.+data ProposedInputs = ProposedInputs {environment :: ![(EnvName, ProposedValue)], files :: ![ProposedFile], inherited :: ![EnvName], notes :: ![Text]} deriving stock (Generic, Eq)++-- | Default execution context: PATH, LANG, LC_ALL, and LC_CTYPE. Inherited names+-- are not deployment coverage and cannot overlap proposed environment entries.+defaultInherited :: [EnvName]+defaultInherited = fmap EnvName ["PATH", "LANG", "LC_ALL", "LC_CTYPE"]++-- | No proposed values or notes, with only 'defaultInherited' execution context.+emptyProposedInputs :: ProposedInputs+emptyProposedInputs = ProposedInputs [] [] defaultInherited []++-- | Disjoint child exit-code categories, with one accepted code. Every code must+-- be in 0..255; unlisted codes are classified as unknown rejection.+data ExitCodeMapping = ExitCodeMapping {accepted :: !Int, usage :: ![Int], sourceFailure :: ![Int], rejected :: ![Int]} deriving stock (Generic, Eq, Show)++-- | Accepted 0, usage 2, source failure 3, and configuration rejection 4.+defaultExitCodes :: ExitCodeMapping+defaultExitCodes = ExitCodeMapping 0 [2] [3] [4]++-- | A positive timeout in seconds and the application-owned exit-code convention.+data PreflightOptions = PreflightOptions {timeoutSeconds :: !Int, exitCodes :: !ExitCodeMapping} deriving stock (Generic, Eq, Show)++-- | A 60-second timeout with 'defaultExitCodes'.+defaultPreflightOptions :: PreflightOptions+defaultPreflightOptions = PreflightOptions 60 defaultExitCodes++-- | An environment name, relative file name, or zero-based extraction-note index.+data InputRef = EnvironmentInput !EnvName | FileInput !FileName | ExtractionNote !Int deriving stock (Generic, Eq, Show)++-- | A structural request problem retaining metadata rather than input values.+-- Use the public validation/runner boundary to sanitize known secrets in metadata.+data PreflightProblem+  = InvalidEnvironmentName !EnvName+  | DuplicateEnvironmentName !EnvName+  | InvalidEnvironmentValue !EnvName+  | InvalidFileName !FileName+  | DuplicateFileName !FileName+  | FilePathConflict !FileName !FileName+  | UnknownFileReference !Int !FileName+  | SecretInArgument !Int !InputRef+  | InvalidInheritedName !EnvName+  | InheritedNameProposed !EnvName+  | DuplicateInheritedName !EnvName+  | InvalidExecutable+  | InvalidArgument !Int+  | SecretInExecutable !InputRef+  | InvalidTimeout+  | InvalidExitCodeMapping+  | EmptyRoleLabel+  deriving stock (Generic, Eq, Show)++-- | Sanitized executable and symbolic arguments; temporary paths are not retained.+data CommandSummary = CommandSummary {executable :: !FilePath, arguments :: ![Text]} deriving stock (Generic, Eq, Show)++-- | Sanitized input identity, coverage classification, and whether it was supplied+-- to a successfully spawned child. This does not prove the application consumed it.+data InputSummary = InputSummary {input :: !InputRef, classification :: !Classification, passed :: !Bool} deriving stock (Generic, Eq, Show)++-- | Whether an input is supplied, synthetic, unresolved, or unavailable, preserving+-- public/secret classification without retaining its value.+data Classification = SuppliedPublic | SuppliedSecret | SyntheticPublic | SyntheticSecret | Unresolved | UnavailableSecretInput deriving stock (Generic, Eq, Show)++-- | Application acceptance, categorized rejection with child exit code, or a harness+-- failure. Partial coverage affects the verdict even when the application accepts.+data Outcome = Accepted | Rejected !RejectionCategory !Int | NotRun !NotRunReason deriving stock (Generic, Eq, Show)++-- | Child exit classification under the caller's 'ExitCodeMapping'.+data RejectionCategory = UsageError | SourceFailure | ConfigurationRejected | UnknownExitCode deriving stock (Generic, Eq, Show)++-- | A sanitized harness failure. A timeout or capture/cleanup failure may occur+-- after the child started; execution details are retained when available.+data NotRunReason = RequestInvalid !(NonEmpty PreflightProblem) | ExecutableNotFound !FilePath | SpawnFailed !Text | MaterializationFailed !Text | CaptureFailed !Text | CleanupFailed !Text | TimedOut !Int deriving stock (Generic, Eq, Show)++-- | Complete proposed-input coverage or a nonempty list of limitations.+-- This does not establish deployed credentials, connectivity, or readiness.+data Coverage = CompleteCoverage | PartialCoverage !(NonEmpty Limitation) deriving stock (Generic, Eq, Show)++-- | One sanitized input or extraction-note reason preventing complete coverage.+data Limitation = Limitation {input :: !InputRef, kind :: !LimitationKind, reason :: !Text} deriving stock (Generic, Eq, Show)++-- | Synthetic, unresolved, unavailable-secret, or incomplete-extraction coverage.+data LimitationKind = SyntheticStandIn | NotYetResolved | SecretUnavailable | ExtractionIncomplete deriving stock (Generic, Eq, Show)++-- | Only Pass passes a gate. Accepted checks with partial coverage are Inconclusive;+-- application rejection or harness failure is Fail regardless of coverage.+data Verdict = Pass | Inconclusive | Fail deriving stock (Generic, Eq, Show)++-- | Sanitized child exit/timeout information and captured output. Output is buffered+-- in memory and redacts exact known nonempty secrets, not transformed values.+data Execution = Execution {exitCode :: !(Maybe Int), timedOut :: !Bool, standardOutput :: !Text, standardError :: !Text} deriving stock (Generic, Eq, Show)++-- Private captured bytes decoded as text, before redaction; deliberately no Show.+data CapturedExecution = CapturedExecution !(Maybe Int) !Bool !Text !Text++-- | A sanitized report with private constructors at the public boundary. Inspect+-- using Settei.Preflight accessors and render only the safe report, not raw inputs.+data PreflightReport = PreflightReport+  {role :: !RoleLabel, command :: !CommandSummary, inputs :: ![InputSummary], inherited :: ![(EnvName, Bool)], notes :: ![Text], outcome :: !Outcome, coverage :: !Coverage, verdict :: !Verdict, execution :: !(Maybe Execution)}+  deriving stock (Generic, Eq, Show)++validEnvironmentName :: EnvName -> Bool+validEnvironmentName (EnvName t) = case T.uncons t of+  Nothing -> False+  Just (c, rest) -> initial c && T.all (\x -> initial x || (isDigit x && x <= '9')) rest+  where+    initial c = isAsciiLower c || isAsciiUpper c || c == '_'++validFileName :: FileName -> Bool+validFileName (FileName t) = not (T.null t) && not (T.any (`elem` ['\0', '\\']) t) && all (\s -> s /= "" && s /= "." && s /= "..") (T.splitOn "/" t)++fileText :: FileName -> Text+fileText (FileName t) = t++envText :: EnvName -> Text+envText (EnvName t) = t++refText :: InputRef -> Text+refText = \case EnvironmentInput n -> envText n; FileInput f -> "file " <> fileText f; ExtractionNote n -> "extraction note " <> tshow n++entries :: ProposedInputs -> [(InputRef, ProposedValue)]+entries p = [(EnvironmentInput n, v) | (n, v) <- p ^. #environment] <> [(FileInput (f ^. #name), f ^. #content) | f <- p ^. #files]++valueText :: ProposedValue -> Maybe Text+valueText = \case SuppliedValue _ v -> Just v; SyntheticValue _ v _ -> Just v; _ -> Nothing++secretValues :: ProposedInputs -> [(Text, Text)]+secretValues p =+  sortOn+    (negate . T.length . fst)+    [(v, marker n ref) | (n, (ref, pv)) <- zip [0 :: Int ..] (entries p), Just v <- [secret pv], not (T.null v)]+  where+    secret = \case SuppliedValue Secret v -> Just v; SyntheticValue Secret v _ -> Just v; _ -> Nothing+    allSecrets = [v | (_, pv) <- entries p, Just v <- [secret pv], not (T.null v)]+    marker n ref = "[redacted:" <> (if any (`T.isInfixOf` refText ref) allSecrets then "input " <> tshow n else refText ref) <> "]"++-- | Match original text once; never rescan inserted markers, even for overlapping secrets.+redact :: [(Text, Text)] -> Text -> Text+redact secrets = T.concat . go+  where+    go t | T.null t = []+    go t = case find (\(s, _) -> s `T.isPrefixOf` t) secrets of+      Just (s, m) -> m : go (T.drop (T.length s) t)+      Nothing -> T.take 1 t : go (T.drop 1 t)++mapRef :: (Text -> Text) -> InputRef -> InputRef+mapRef f = \case EnvironmentInput n -> EnvironmentInput (EnvName (f (envText n))); FileInput n -> FileInput (FileName (f (fileText n))); ExtractionNote n -> ExtractionNote n++mapProblem :: (Text -> Text) -> PreflightProblem -> PreflightProblem+mapProblem f = \case+  InvalidEnvironmentName n -> InvalidEnvironmentName (en n)+  DuplicateEnvironmentName n -> DuplicateEnvironmentName (en n)+  InvalidEnvironmentValue n -> InvalidEnvironmentValue (en n)+  InvalidFileName n -> InvalidFileName (fn n)+  DuplicateFileName n -> DuplicateFileName (fn n)+  FilePathConflict a b -> FilePathConflict (fn a) (fn b)+  UnknownFileReference n a -> UnknownFileReference n (fn a)+  SecretInArgument n a -> SecretInArgument n (mapRef f a)+  InvalidInheritedName n -> InvalidInheritedName (en n)+  InheritedNameProposed n -> InheritedNameProposed (en n)+  DuplicateInheritedName n -> DuplicateInheritedName (en n)+  SecretInExecutable a -> SecretInExecutable (mapRef f a)+  a -> a+  where+    en = EnvName . f . envText; fn = FileName . f . fileText++-- | Pure request validation: names, paths, references, timeout, exit codes, and+-- known-secret command exposure. It performs no IO or application execution.+validatePreflight :: PreflightOptions -> RoleLabel -> CheckCommand -> ProposedInputs -> Either (NonEmpty PreflightProblem) ()+validatePreflight opts role cmd p = case validatePreflightRaw opts role cmd p of+  Left ps -> Left (fmap (mapProblem (redact (secretValues p))) ps)+  Right () -> Right ()++validatePreflightRaw :: PreflightOptions -> RoleLabel -> CheckCommand -> ProposedInputs -> Either (NonEmpty PreflightProblem) ()+validatePreflightRaw opts (RoleLabel label) cmd p = maybe (Right ()) Left (NE.nonEmpty problems)+  where+    es = p ^. #environment+    fs = fmap (view #name) (p ^. #files)+    ins = p ^. #inherited+    problems =+      [EmptyRoleLabel | T.null (T.strip label)]+        <> [InvalidExecutable | null (cmd ^. #executable) || '\0' `elem` (cmd ^. #executable)]+        <> [InvalidTimeout | opts ^. #timeoutSeconds <= 0 || opts ^. #timeoutSeconds > maxBound `div` 1000000]+        <> [InvalidExitCodeMapping | not (validExitCodes (opts ^. #exitCodes))]+        <> concat [[InvalidEnvironmentName n | not (validEnvironmentName n)] <> [DuplicateEnvironmentName n | n `elem` take i (fmap fst es)] <> [InvalidEnvironmentValue n | maybe False (T.any (== '\0')) (valueText v)] | (i, (n, v)) <- zip [0 ..] es]+        <> concat [[InvalidFileName f | not (validFileName f)] <> [DuplicateFileName f | f `elem` take i fs] <> [FilePathConflict a f | a <- take i fs, parent a f || parent f a] | (i, f) <- zip [0 ..] fs]+        <> concat [[InvalidInheritedName n | not (validEnvironmentName n)] <> [DuplicateInheritedName n | n `elem` take i ins] <> [InheritedNameProposed n | n `elem` fmap fst es] | (i, n) <- zip [0 ..] ins]+        <> concat [[InvalidArgument i | T.any (== '\0') (summaryArgument a)] <> [UnknownFileReference i f | part <- argumentParts a, f <- case part of FilePart n -> [n | n `notElem` fs]; DirectoryPart n -> [n | not (validFileName n) || not (any (parent n) fs)]; _ -> []] | (i, a) <- zip [0 ..] (cmd ^. #arguments)]+        <> secretCommandProblems p (cmd ^. #executable) (fmap summaryArgument (cmd ^. #arguments))+    argumentParts (Argument ps) = ps+    parent a b = (fileText a <> "/") `T.isPrefixOf` fileText b++validExitCodes :: ExitCodeMapping -> Bool+validExitCodes m = all (\n -> n >= 0 && n <= 255) codes && length codes == length (unique codes)+  where+    codes = [m ^. #accepted] <> m ^. #usage <> m ^. #sourceFailure <> m ^. #rejected++unique :: (Eq a) => [a] -> [a]+unique = foldl (\xs x -> if x `elem` xs then xs else xs <> [x]) []++summaryArgument :: Argument -> Text+summaryArgument (Argument ps) = foldMap (\case LiteralPart t -> t; FilePart f -> "{file:" <> fileText f <> "}"; DirectoryPart f -> "{dir:" <> fileText f <> "}") ps++secretCommandProblems :: ProposedInputs -> FilePath -> [Text] -> [PreflightProblem]+secretCommandProblems p exe args = concatMap check (entries p)+  where+    check (ref, v) = case v of+      SuppliedValue Secret t -> matches ref t+      SyntheticValue Secret t _ -> matches ref t+      _ -> []+    matches _ t | T.null t = []+    matches ref t = [SecretInExecutable ref | t `T.isInfixOf` T.pack exe] <> [SecretInArgument i ref | (i, a) <- zip [0 ..] args, t `T.isInfixOf` a]++-- | Private operations make failures deterministic to test without weakening the public API.+data RunOperations = RunOperations+  { materializeInputs :: !(FilePath -> ProposedInputs -> CheckCommand -> IO ()),+    captureOutput :: !(IO.Handle -> IO BS.ByteString),+    removeInputs :: !(FilePath -> IO ()),+    createCheckProcess :: !(P.CreateProcess -> IO (Maybe IO.Handle, Maybe IO.Handle, Maybe IO.Handle, P.ProcessHandle))+  }+  deriving stock (Generic)++defaultRunOperations :: RunOperations+defaultRunOperations = RunOperations materialize (\h -> BS.hGetSome h 32768) D.removePathForcibly P.createProcess++materialize :: FilePath -> ProposedInputs -> CheckCommand -> IO ()+materialize root p cmd = do+  forM_ (p ^. #files) $ \f -> case valueText (f ^. #content) of+    Nothing -> pure ()+    Just v -> do+      let path = root </> T.unpack (fileText (f ^. #name))+      D.createDirectoryIfMissing True (takeDirectory path)+      BS.writeFile path (TE.encodeUtf8 v)+  mapM_ (\(Argument ps) -> mapM_ (\case DirectoryPart n -> D.createDirectoryIfMissing True (root </> T.unpack (fileText n)); _ -> pure ()) ps) (cmd ^. #arguments)++materializedArguments :: FilePath -> CheckCommand -> [Text]+materializedArguments root cmd = fmap (\(Argument ps) -> foldMap (\case LiteralPart t -> t; FilePart f -> path f; DirectoryPart f -> path f) ps) (cmd ^. #arguments)+  where+    path f = T.pack (root </> T.unpack (fileText f))++tryIO :: IO a -> IO (Either IOException a)+tryIO = try++-- | Validate, materialize in a fresh 0700 directory, run the check, redact output,+-- and verify cleanup before returning. Timeout includes pipe draining and requests+-- SIGTERM; resistant children/descendants are not force-killed. Use trusted checks+-- with bounded output. This runner is not a sandbox.+runPreflight :: PreflightOptions -> RoleLabel -> CheckCommand -> ProposedInputs -> IO PreflightReport+runPreflight = runPreflightWith defaultRunOperations++runPreflightWith :: RunOperations -> PreflightOptions -> RoleLabel -> CheckCommand -> ProposedInputs -> IO PreflightReport+runPreflightWith ops opts label cmd p = do+  ambient <- Env.getEnvironment+  let inheritedValues = [(n, lookup (T.unpack (envText n)) ambient) | n <- p ^. #inherited]+      childEnv = [(T.unpack (envText n), v) | (n, Just v) <- inheritedValues] <> [(T.unpack (envText n), T.unpack v) | (n, pv) <- p ^. #environment, Just v <- [valueText pv]]+      finish exe started result = pure (makeReport opts label exe (cmd ^. #arguments) p (fmap (\(n, v) -> (n, maybe False (const True) v)) inheritedValues) started result)+  case validatePreflightRaw opts label cmd p of+    Left problems -> finish (cmd ^. #executable) False (Left (RequestInvalid problems), Nothing)+    Right () -> do+      resolved <-+        tryIO $+          if '/' `elem` (cmd ^. #executable)+            then do path <- D.makeAbsolute (cmd ^. #executable); exists <- D.doesFileExist path; pure (if exists then Just path else Nothing)+            else D.findExecutable (cmd ^. #executable) >>= traverse D.makeAbsolute+      case resolved of+        Left _ -> finish (cmd ^. #executable) False (Left (SpawnFailed "executable lookup failed"), Nothing)+        Right Nothing -> finish (cmd ^. #executable) False (Left (ExecutableNotFound (cmd ^. #executable)), Nothing)+        Right (Just exe) -> do+          attempted <- tryIO $ withSystemTempDirectory "settei-preflight" $ \root -> do+            written <- tryIO ((ops ^. #materializeInputs) root p cmd)+            result <- case written of+              Left _ -> pure (False, (Left (MaterializationFailed "input files could not be written"), Nothing))+              Right () -> case NE.nonEmpty (secretCommandProblems p exe (materializedArguments root cmd)) of+                Just ps -> pure (False, (Left (RequestInvalid ps), Nothing))+                Nothing -> execute ops opts root exe (materializedArguments root cmd) childEnv+            removed <- tryIO $ do+              (ops ^. #removeInputs) root+              remains <- D.doesPathExist root+              when remains (ioError (userError "cleanup incomplete"))+            pure $ case removed of+              Left _ -> (fst result, (Left (CleanupFailed "temporary input directory could not be removed"), snd (snd result)))+              Right () -> result+          case attempted of+            Left _ -> finish exe False (Left (MaterializationFailed "temporary input directory could not be created"), Nothing)+            Right (started, result) -> finish exe started result++-- Reader scopes are INSIDE the process bracket: cancellation joins before hClose.+execute :: RunOperations -> PreflightOptions -> FilePath -> FilePath -> [Text] -> [(String, String)] -> IO (Bool, (Either NotRunReason Int, Maybe CapturedExecution))+execute ops opts root exe args environmentValues = do+  out <- newIORef []+  err <- newIORef []+  spawned <- newIORef False+  events <- newChan+  let reader h buffer = do+        chunk <- (ops ^. #captureOutput) h+        unless (BS.null chunk) (modifyIORef' buffer (chunk :) >> reader h buffer)+      scoped action continuation =+        bracket+          ( do+              done <- newEmptyMVar+              tid <- forkFinally action (\result -> putMVar done () >> writeChan events result)+              pure (tid, done)+          )+          (\(tid, done) -> killThread tid >> void (readMVar done))+          (const continuation)+      collect 0 code = pure code+      collect remaining code =+        readChan events >>= \case+          Right found -> collect (remaining - 1 :: Int) (maybe code id found)+          Left e -> case fromException e of+            Just ioe -> throwIO (ioe :: IOException)+            Nothing -> throwIO e+      process = (P.proc exe (fmap T.unpack args)) {P.cwd = Just root, P.env = Just environmentValues, P.std_in = P.CreatePipe, P.std_out = P.CreatePipe, P.std_err = P.CreatePipe}+  result <- tryIO $ bracket ((ops ^. #createCheckProcess) process) P.cleanupProcess $ \(mi, mo, me, ph) -> do+    modifyIORef' spawned (const True)+    case (mi, mo, me) of+      (Just hi, Just ho, Just he) -> do+        mapM_ (`IO.hSetBinaryMode` True) [hi, ho, he]+        IO.hClose hi+        scoped (reader ho out >> pure Nothing) $+          scoped (reader he err >> pure Nothing) $+            scoped (Just . exitInt <$> P.waitForProcess ph) $+              timeout (opts ^. #timeoutSeconds * 1000000) (tryIO (collect 3 0))+      _ -> ioError (userError "missing pipes")+  didSpawn <- readIORef spawned+  stdout <- TE.decodeUtf8With lenientDecode . BS.concat . reverse <$> readIORef out+  stderr <- TE.decodeUtf8With lenientDecode . BS.concat . reverse <$> readIORef err+  let outcomeValue = case result of+        Left _ -> Left (if didSpawn then CaptureFailed "process output could not be captured" else SpawnFailed "process could not be started")+        Right Nothing -> Left (TimedOut (opts ^. #timeoutSeconds))+        Right (Just (Left _)) -> Left (CaptureFailed "process output could not be captured")+        Right (Just (Right ec)) -> Right ec+      executionValue = if didSpawn then Just (CapturedExecution (either (const Nothing) Just outcomeValue) (case outcomeValue of Left (TimedOut _) -> True; _ -> False) stdout stderr) else Nothing+  pure (didSpawn, (outcomeValue, executionValue))+  where+    exitInt ExitSuccess = 0+    exitInt (ExitFailure n) = n++makeReport :: PreflightOptions -> RoleLabel -> FilePath -> [Argument] -> ProposedInputs -> [(EnvName, Bool)] -> Bool -> (Either NotRunReason Int, Maybe CapturedExecution) -> PreflightReport+makeReport opts (RoleLabel label) exe args p inheritedValues started (result, ex) =+  PreflightReport (RoleLabel (safe label)) (CommandSummary (T.unpack (safe (T.pack exe))) (fmap (safe . summaryArgument) args)) summaries (fmap (\(n, b) -> (EnvName (safe (envText n)), b)) inheritedValues) (fmap safe (p ^. #notes)) outcomeValue coverageValue verdictValue executionValue+  where+    safe = redact (secretValues p)+    sorted = sortOn (\(r, _) -> case r of EnvironmentInput n -> (0 :: Int, envText n); FileInput n -> (1, fileText n); ExtractionNote n -> (2, tshow n)) (entries p)+    classify = \case SuppliedValue Public _ -> SuppliedPublic; SuppliedValue Secret _ -> SuppliedSecret; SyntheticValue Public _ _ -> SyntheticPublic; SyntheticValue Secret _ _ -> SyntheticSecret; UnresolvedValue _ -> Unresolved; UnavailableSecret _ -> UnavailableSecretInput+    summaries = [InputSummary (mapRef safe r) (classify v) (started && maybe False (const True) (valueText v)) | (r, v) <- sorted]+    limits =+      [Limitation (mapRef safe r) k (safe reason) | (r, v) <- sorted, (k, reason) <- case v of SyntheticValue _ _ why -> [(SyntheticStandIn, why)]; UnresolvedValue why -> [(NotYetResolved, why)]; UnavailableSecret why -> [(SecretUnavailable, why)]; _ -> []]+        <> [Limitation (ExtractionNote n) ExtractionIncomplete (safe note) | (n, note) <- zip [0 ..] (p ^. #notes)]+    coverageValue = maybe CompleteCoverage PartialCoverage (NE.nonEmpty limits)+    outcomeValue = either (NotRun . sanitizeNotRun safe) (classifyExit (opts ^. #exitCodes)) result+    verdictValue = case outcomeValue of Accepted -> if null limits then Pass else Inconclusive; _ -> Fail+    executionValue = fmap (\(CapturedExecution ec timed stdout stderr) -> Execution ec timed (safe stdout) (safe stderr)) ex++classifyExit :: ExitCodeMapping -> Int -> Outcome+classifyExit m n | n == m ^. #accepted = Accepted+classifyExit m n = Rejected (if n `elem` m ^. #usage then UsageError else if n `elem` m ^. #sourceFailure then SourceFailure else if n `elem` m ^. #rejected then ConfigurationRejected else UnknownExitCode) n++sanitizeNotRun :: (Text -> Text) -> NotRunReason -> NotRunReason+sanitizeNotRun f = \case+  RequestInvalid ps -> RequestInvalid (fmap (mapProblem f) ps)+  ExecutableNotFound p -> ExecutableNotFound (T.unpack (f (T.pack p)))+  SpawnFailed t -> SpawnFailed (f t)+  MaterializationFailed t -> MaterializationFailed (f t)+  CaptureFailed t -> CaptureFailed (f t)+  CleanupFailed t -> CleanupFailed (f t)+  TimedOut n -> TimedOut n++tshow :: (Show a) => a -> Text+tshow = T.pack . show++problemFields :: PreflightProblem -> (Text, Maybe Text, Maybe Int, Maybe Text)+problemFields = \case+  InvalidEnvironmentName n -> named "invalid-environment-name" (envText n)+  DuplicateEnvironmentName n -> named "duplicate-environment-name" (envText n)+  InvalidEnvironmentValue n -> named "invalid-environment-value" (envText n)+  InvalidFileName n -> named "invalid-file-name" (fileText n)+  DuplicateFileName n -> named "duplicate-file-name" (fileText n)+  FilePathConflict a b -> ("file-path-conflict", Just (fileText a), Nothing, Just (fileText b))+  UnknownFileReference i n -> ("unknown-file-reference", Just (fileText n), Just i, Nothing)+  SecretInArgument i r -> ("secret-in-argument", Just (refText r), Just i, Nothing)+  InvalidInheritedName n -> named "invalid-inherited-name" (envText n)+  InheritedNameProposed n -> named "inherited-name-proposed" (envText n)+  DuplicateInheritedName n -> named "duplicate-inherited-name" (envText n)+  SecretInExecutable r -> named "secret-in-executable" (refText r)+  InvalidExecutable -> bare "invalid-executable"+  InvalidArgument i -> ("invalid-argument", Nothing, Just i, Nothing)+  InvalidTimeout -> bare "invalid-timeout"+  InvalidExitCodeMapping -> bare "invalid-exit-code-mapping"+  EmptyRoleLabel -> bare "empty-role-label"+  where+    named k n = (k, Just n, Nothing, Nothing); bare k = (k, Nothing, Nothing, Nothing)++-- | Render one sanitized structural problem as one physical line.+renderPreflightProblemText :: PreflightProblem -> Text+renderPreflightProblemText p = let (k, n, a, r) = problemFields p in k <> maybe "" ((": " <>) . oneLine) n <> maybe "" (\i -> " (argument " <> tshow i <> ")") a <> maybe "" ((" conflicts with " <>) . oneLine) r++-- Preserve the one-problem-per-line contract even for invalid names.+oneLine :: Text -> Text+oneLine = T.concatMap (\case '\n' -> "\\n"; '\r' -> "\\r"; '\t' -> "\\t"; '\0' -> "\\0"; c -> T.singleton c)++-- | Render sanitized request problems in order, one per line.+renderPreflightProblemsText :: NonEmpty PreflightProblem -> Text+renderPreflightProblemsText = T.unlines . fmap renderPreflightProblemText . NE.toList++verdictText :: Verdict -> Text+verdictText = \case Pass -> "pass"; Inconclusive -> "inconclusive"; Fail -> "fail"++categoryText :: RejectionCategory -> Text+categoryText = \case UsageError -> "usage"; SourceFailure -> "source-failure"; ConfigurationRejected -> "configuration-rejected"; UnknownExitCode -> "unknown-exit-code"++notRunFields :: NotRunReason -> (Text, Maybe Text, [PreflightProblem])+notRunFields = \case+  RequestInvalid ps -> ("request-invalid", Nothing, NE.toList ps)+  ExecutableNotFound p -> ("executable-not-found", Just (T.pack p), [])+  SpawnFailed t -> ("spawn-failed", Just t, [])+  MaterializationFailed t -> ("materialization-failed", Just t, [])+  CaptureFailed t -> ("capture-failed", Just t, [])+  CleanupFailed t -> ("cleanup-failed", Just t, [])+  TimedOut n -> ("timed-out", Just (tshow n <> " seconds"), [])++verdictExplanation :: PreflightReport -> Text+verdictExplanation r = case r ^. #outcome of+  Accepted -> if r ^. #verdict == Pass then "accepted with complete coverage" else "accepted with partial coverage; not a deployment validation"+  Rejected c n -> (case c of UsageError -> "usage error"; SourceFailure -> "source loading failed"; ConfigurationRejected -> "configuration rejected"; UnknownExitCode -> "unknown exit code") <> ", exit " <> tshow n+  NotRun why -> let (k, d, ps) = notRunFields why in "not run: " <> T.replace "-" " " k <> maybe "" (": " <>) d <> (if null ps then "" else ": " <> T.intercalate "; " (fmap renderPreflightProblemText ps))++classificationText :: Classification -> Text+classificationText = \case SuppliedPublic -> "supplied-public"; SuppliedSecret -> "supplied-secret"; SyntheticPublic -> "synthetic-public"; SyntheticSecret -> "synthetic-secret"; Unresolved -> "unresolved"; UnavailableSecretInput -> "unavailable-secret"++limitationText :: LimitationKind -> Text+limitationText = \case SyntheticStandIn -> "synthetic-stand-in"; NotYetResolved -> "not-yet-resolved"; SecretUnavailable -> "secret-unavailable"; ExtractionIncomplete -> "extraction-incomplete"++limitations :: Coverage -> [Limitation]+limitations = \case CompleteCoverage -> []; PartialCoverage ls -> NE.toList ls++-- | Render a deterministic human-readable verdict, coverage, inputs, and safe output.+renderPreflightReportText :: PreflightReport -> Text+renderPreflightReportText r =+  T.unlines $+    ["preflight " <> label <> ": " <> verdictText (r ^. #verdict) <> " (" <> verdictExplanation r <> ")", "coverage: " <> (case r ^. #coverage of CompleteCoverage -> "complete"; _ -> "partial")]+      <> ["  " <> refText (l ^. #input) <> ": " <> (case l ^. #kind of SyntheticStandIn -> "synthetic stand-in"; k -> T.replace "-" " " (limitationText k)) <> " (" <> l ^. #reason <> ")" | l <- limitations (r ^. #coverage)]+      <> (if null (r ^. #inputs) then ["inputs: (none)"] else "inputs:" : ["  " <> refText (s ^. #input) <> ": " <> T.replace "-" " " (classificationText (s ^. #classification)) <> (if s ^. #passed then "" else " (omitted)") | s <- r ^. #inputs])+      <> ["inherited: " <> (if null (r ^. #inherited) then "(none)" else T.intercalate ", " [envText n <> if present then " (present)" else " (absent)" | (n, present) <- r ^. #inherited]), "command: " <> T.unwords (T.pack (r ^. #command . #executable) : r ^. #command . #arguments)]+      <> (if null (r ^. #notes) then [] else "notes:" : fmap ("  " <>) (r ^. #notes))+      <> block "stdout" (maybe "" (view #standardOutput) (r ^. #execution))+      <> block "stderr" (maybe "" (view #standardError) (r ^. #execution))+  where+    RoleLabel label = r ^. #role+    block name t = (name <> ":") : if T.null t then ["  (empty)"] else fmap ("  " <>) (T.lines t)++encodingText :: A.Encoding -> Text+encodingText = TE.decodeUtf8 . BL.toStrict . E.encodingToLazyByteString++obj :: [(A.Key, A.Encoding)] -> A.Encoding+obj = E.pairs . foldMap (uncurry E.pair)++json :: (A.ToJSON a) => a -> A.Encoding+json = A.toEncoding++refFields :: InputRef -> [(A.Key, A.Encoding)]+refFields ref = [("channel", json channel), ("name", json name)]+  where+    (channel, name) = case ref of EnvironmentInput n -> ("environment" :: Text, envText n); FileInput f -> ("file", fileText f); ExtractionNote n -> ("extraction", tshow n)++-- | Render deterministic settei.preflight schema-version-1 JSON. Report fields+-- contain sanitized metadata and output, without proposed raw values.+renderPreflightReportJson :: PreflightReport -> Text+renderPreflightReportJson r =+  encodingText $+    obj+      [("schemaVersion", json (1 :: Int)), ("type", json ("settei.preflight" :: Text)), ("role", json label), ("verdict", json (verdictText (r ^. #verdict))), ("verdictExplanation", json (verdictExplanation r)), ("outcome", outcomeJson (r ^. #outcome)), ("coverage", obj [("kind", json (case r ^. #coverage of CompleteCoverage -> "complete" :: Text; _ -> "partial")), ("limitations", E.list limitJson (limitations (r ^. #coverage)))]), ("inputs", E.list inputJson (r ^. #inputs)), ("inherited", E.list (\(n, b) -> obj [("name", json (envText n)), ("present", json b)]) (r ^. #inherited)), ("notes", json (r ^. #notes)), ("command", obj [("executable", json (r ^. #command . #executable)), ("arguments", json (r ^. #command . #arguments))]), ("execution", maybe E.null_ executionJson (r ^. #execution))]+  where+    RoleLabel label = r ^. #role+    inputJson s = obj (refFields (s ^. #input) <> [("classification", json (classificationText (s ^. #classification))), ("passed", json (s ^. #passed))])+    limitJson l = obj (refFields (l ^. #input) <> [("limitation", json (limitationText (l ^. #kind))), ("reason", json (l ^. #reason))])+    executionJson e = obj [("exitCode", json (e ^. #exitCode)), ("timedOut", json (e ^. #timedOut)), ("stdout", json (e ^. #standardOutput)), ("stderr", json (e ^. #standardError))]+    outcomeJson = \case+      Accepted -> obj [("kind", json ("accepted" :: Text))]+      Rejected c n -> obj [("kind", json ("rejected" :: Text)), ("category", json (categoryText c)), ("exitCode", json n)]+      NotRun why -> let (k, d, ps) = notRunFields why in obj [("kind", json ("not-run" :: Text)), ("reason", json k), ("detail", json d), ("problems", E.list problemJson ps)]+    problemJson p = let (k, n, a, related) = problemFields p in obj ([("kind", json k), ("name", json n), ("argument", json a)] <> maybe [] (\v -> [("relatedName", json v)]) related)
+ src/Settei/Preflight/Request.hs view
@@ -0,0 +1,265 @@+-- | Strict version-one request documents for deployment tooling.+-- Public values and fake stand-ins may be inline. Real secrets use references.+-- Parser diagnostics retain only schema paths and fixed messages, never input text.+module Settei.Preflight.Request+  ( PreflightRequest (..),+    RequestEntry (..),+    EntryForm (..),+    preflightRequest,+    parsePreflightRequest,+    renderPreflightRequestJson,+    RequestError,+    renderRequestErrorText,+    renderRequestErrorsText,+    RequestEnvironment (..),+    processRequestEnvironment,+    ResolvedRequest (..),+    resolveRequest,+  )+where++import Control.Monad (unless)+import Data.Aeson qualified as A+import Data.Aeson.Encoding qualified as E+import Data.Aeson.Key qualified as K+import Data.Aeson.KeyMap qualified as KM+import Data.ByteString qualified as BS+import Data.Generics.Labels ()+import Data.List.NonEmpty qualified as NE+import Data.Text qualified as T+import Data.Text.Encoding qualified as TE+import Settei (Sensitivity (..))+import Settei.Env (EnvName (..))+import Settei.Preflight.Internal+import Settei.Prelude+import System.Environment qualified as Env++-- No Show on any request-bearing type: callers must not log unresolved/raw inputs.++-- | A version-one tooling request before secret-reference resolution. Inline values+-- must be public or fake stand-ins. No Show instance prevents accidental raw logging.+data PreflightRequest = PreflightRequest+  { role :: !RoleLabel,+    command :: !CheckCommand,+    environment :: ![RequestEntry],+    files :: ![RequestEntry],+    inherit :: !(Maybe [EnvName]),+    timeoutSeconds :: !Int,+    exitCodes :: !ExitCodeMapping,+    notes :: ![Text]+  }+  deriving stock (Generic, Eq)++-- | A proposed environment/file name with exactly one value form.+data RequestEntry = RequestEntry {name :: !Text, form :: !EntryForm} deriving stock (Generic, Eq)++-- | A public inline value, secret reference, fake synthetic value with sensitivity+-- and reason, unresolved input, or unavailable secret. Real secrets use references.+data EntryForm+  = InlineValue !Text+  | SecretFromEnvironment !EnvName+  | SecretFromFile !FilePath+  | SyntheticEntry !Sensitivity !Text !Text+  | UnresolvedEntry !Text+  | UnavailableSecretEntry !Text+  deriving stock (Generic, Eq)++-- | A safe schema path and fixed diagnostic, without input excerpts or values.+newtype RequestError = RequestError Text deriving stock (Generic, Eq, Show)++-- | Render one safe request error without raw parser details.+renderRequestErrorText :: RequestError -> Text+renderRequestErrorText (RequestError t) = t++-- | Render request errors in order, one per line.+renderRequestErrorsText :: NonEmpty RequestError -> Text+renderRequestErrorsText = T.unlines . fmap renderRequestErrorText . NE.toList++-- | Construct a request with a 60-second timeout, default exit codes, no notes,+-- and the default inheritance policy. The runner validates the assembled proposal.+preflightRequest :: RoleLabel -> CheckCommand -> [RequestEntry] -> [RequestEntry] -> PreflightRequest+preflightRequest r c es fs = PreflightRequest r c es fs Nothing 60 defaultExitCodes []++-- A deliberately small closed-schema parser. Aeson's syntax/type errors are never retained.+type Parsed a = Either RequestError a++bad :: Text -> Text -> Parsed a+bad path message = Left (RequestError (path <> ": " <> message))++objectAt :: Text -> [Text] -> A.Value -> Parsed A.Object+objectAt path allowed = \case+  A.Object o -> if all ((`elem` allowed) . K.toText) (KM.keys o) then Right o else bad path "unknown key"+  _ -> bad path "expected object"++required :: Text -> A.Object -> Text -> Parsed A.Value+required path o key = maybe (bad (path <> "." <> key) "required field is missing") Right (KM.lookup (K.fromText key) o)++textAt :: Text -> A.Value -> Parsed Text+textAt path = \case A.String t -> Right t; _ -> bad path "expected string"++intAt :: Text -> A.Value -> Parsed Int+intAt path v = case A.fromJSON v of A.Success n -> Right n; A.Error _ -> bad path "expected integer"++arrayAt :: Text -> (Text -> A.Value -> Parsed a) -> A.Value -> Parsed [a]+arrayAt path parse v = case A.fromJSON v of+  A.Success values -> sequence [parse (path <> "[" <> tshow i <> "]") value | (i, value) <- zip [0 :: Int ..] values]+  A.Error _ -> bad path "expected array"++optional :: Text -> A.Object -> Text -> a -> (Text -> A.Value -> Parsed a) -> Parsed a+optional path o key fallback parse = maybe (Right fallback) (parse (path <> "." <> key)) (KM.lookup (K.fromText key) o)++field :: Text -> A.Object -> Text -> (Text -> A.Value -> Parsed a) -> Parsed a+field path o key parse = required path o key >>= parse (path <> "." <> key)++-- | Parse strict settei.preflight-request schema-version-1 UTF-8 JSON. Reject+-- unknown keys, conflicting forms, and unsupported versions without source excerpts.+-- Structural proposal validation still occurs at the runner boundary.+parsePreflightRequest :: BS.ByteString -> Either (NonEmpty RequestError) PreflightRequest+parsePreflightRequest bytes = case A.eitherDecodeStrict bytes of+  Left _ -> Left (RequestError "request: invalid JSON" :| [])+  Right value -> case parseRequest value of Left e -> Left (e :| []); Right request -> Right request++parseRequest :: A.Value -> Parsed PreflightRequest+parseRequest value = do+  let path = "request"+  o <- objectAt path ["schemaVersion", "type", "role", "command", "environment", "files", "inherit", "timeoutSeconds", "exitCodes", "notes"] value+  version <- field path o "schemaVersion" intAt+  unless (version == 1) (bad "request.schemaVersion" "unsupported version")+  typ <- field path o "type" textAt+  unless (typ == "settei.preflight-request") (bad "request.type" "unexpected document type")+  label <- field path o "role" textAt+  unless (not (T.null (T.strip label))) (bad "request.role" "must not be empty")+  cmd <- field path o "command" parseCommand+  es <- field path o "environment" (\p -> arrayAt p parseEntry)+  fs <- optional path o "files" [] (\p -> arrayAt p parseEntry)+  inh <- case KM.lookup "inherit" o of Nothing -> Right Nothing; Just v -> Just <$> arrayAt "request.inherit" parseEnvName v+  seconds <- optional path o "timeoutSeconds" 60 intAt+  unless (seconds > 0 && seconds <= maxBound `div` 1000000) (bad "request.timeoutSeconds" "outside supported range")+  codes <- optional path o "exitCodes" defaultExitCodes parseCodes+  ns <- optional path o "notes" [] (\p -> arrayAt p textAt)+  pure (PreflightRequest (RoleLabel label) cmd es fs inh seconds codes ns)++parseEnvName :: Text -> A.Value -> Parsed EnvName+parseEnvName path v = do+  n <- EnvName <$> textAt path v+  if validEnvironmentName n then Right n else bad path "invalid environment variable name"++parseCommand :: Text -> A.Value -> Parsed CheckCommand+parseCommand path value = do+  o <- objectAt path ["executable", "arguments"] value+  exe <- field path o "executable" textAt+  args <- field path o "arguments" (\p -> arrayAt p parseArgument)+  pure (CheckCommand (T.unpack exe) args)++parseArgument :: Text -> A.Value -> Parsed Argument+parseArgument path value = do+  o <- objectAt path ["literal", "file", "directory", "parts"] value+  if KM.size o /= 1+    then bad path "expected exactly one argument form"+    else case KM.lookup "parts" o of+      Just v -> Argument <$> arrayAt (path <> ".parts") parsePart v+      Nothing -> Argument . pure <$> parsePart path value++parsePart :: Text -> A.Value -> Parsed ArgumentPart+parsePart path value = do+  o <- objectAt path ["literal", "file", "directory"] value+  if KM.size o /= 1+    then bad path "expected exactly one argument part"+    else case () of+      _+        | Just v <- KM.lookup "literal" o -> LiteralPart <$> textAt (path <> ".literal") v+        | Just v <- KM.lookup "file" o -> FilePart . FileName <$> textAt (path <> ".file") v+        | Just v <- KM.lookup "directory" o -> DirectoryPart . FileName <$> textAt (path <> ".directory") v+        | otherwise -> bad path "expected argument part"++parseEntry :: Text -> A.Value -> Parsed RequestEntry+parseEntry path value = do+  o <- objectAt path ["name", "value", "secretFromEnvironment", "secretFromFile", "synthetic", "reason", "sensitivity", "unresolved", "unavailableSecret"] value+  name <- field path o "name" textAt+  let forms = [k | k <- ["value", "secretFromEnvironment", "secretFromFile", "synthetic", "unresolved", "unavailableSecret"], KM.member k o]+  unless (length forms == 1) (bad path "expected exactly one value form")+  entry <- case forms of+    ["synthetic"] -> do+      standin <- field path o "synthetic" textAt+      reason <- field path o "reason" textAt+      sensitivity <- optional path o "sensitivity" Public (\p v -> textAt p v >>= \case "public" -> Right Public; "secret" -> Right Secret; _ -> bad p "expected public or secret")+      pure (SyntheticEntry sensitivity standin reason)+    [k] -> do+      unless (not (KM.member "reason" o || KM.member "sensitivity" o)) (bad path "reason and sensitivity require a synthetic entry; real secrets must use references")+      case k of+        "value" -> InlineValue <$> field path o "value" textAt+        "secretFromEnvironment" -> SecretFromEnvironment <$> field path o "secretFromEnvironment" parseEnvName+        "secretFromFile" -> SecretFromFile . T.unpack <$> field path o "secretFromFile" textAt+        "unresolved" -> UnresolvedEntry <$> field path o "unresolved" textAt+        _ -> UnavailableSecretEntry <$> field path o "unavailableSecret" textAt+    _ -> bad path "expected value form"+  pure (RequestEntry name entry)++parseCodes :: Text -> A.Value -> Parsed ExitCodeMapping+parseCodes path value = do+  o <- objectAt path ["accepted", "usage", "sourceFailure", "rejected"] value+  codes <- ExitCodeMapping <$> field path o "accepted" intAt <*> field path o "usage" (\p -> arrayAt p intAt) <*> field path o "sourceFailure" (\p -> arrayAt p intAt) <*> field path o "rejected" (\p -> arrayAt p intAt)+  if validExitCodes codes then Right codes else bad path "exit codes must be distinct integers in 0..255"++-- | Serialize a request deterministically without resolving secret references.+-- Inline public/fake values are retained, so this is not a redaction or logging API.+renderPreflightRequestJson :: PreflightRequest -> Text+renderPreflightRequestJson r =+  encodingText $+    obj $+      [("schemaVersion", json (1 :: Int)), ("type", json ("settei.preflight-request" :: Text)), ("role", json label), ("command", obj [("executable", json (r ^. #command . #executable)), ("arguments", E.list argumentJson (r ^. #command . #arguments))]), ("environment", E.list entryJson (r ^. #environment)), ("files", E.list entryJson (r ^. #files))]+        <> maybe [] (\names -> [("inherit", json (fmap envText names))]) (r ^. #inherit)+        <> [("timeoutSeconds", json (r ^. #timeoutSeconds)), ("exitCodes", obj [("accepted", json (codes ^. #accepted)), ("usage", json (codes ^. #usage)), ("sourceFailure", json (codes ^. #sourceFailure)), ("rejected", json (codes ^. #rejected))]), ("notes", json (r ^. #notes))]+  where+    RoleLabel label = r ^. #role+    codes = r ^. #exitCodes+    argumentJson (Argument [p]) = partJson p+    argumentJson (Argument ps) = obj [("parts", E.list partJson ps)]+    partJson = \case LiteralPart t -> obj [("literal", json t)]; FilePart n -> obj [("file", json (fileText n))]; DirectoryPart n -> obj [("directory", json (fileText n))]+    entryJson e =+      obj+        ( [("name", json (e ^. #name))] <> case e ^. #form of+            InlineValue t -> [("value", json t)]+            SecretFromEnvironment n -> [("secretFromEnvironment", json (envText n))]+            SecretFromFile p -> [("secretFromFile", json p)]+            SyntheticEntry sensitivity t reason -> [("synthetic", json t), ("reason", json reason), ("sensitivity", json (case sensitivity of Public -> "public" :: Text; Secret -> "secret"))]+            UnresolvedEntry t -> [("unresolved", json t)]+            UnavailableSecretEntry t -> [("unavailableSecret", json t)]+        )++-- | Injectable harness-side variable lookup and UTF-8 secret-file reads.+-- References do not imply inheritance into the checked child.+data RequestEnvironment = RequestEnvironment {lookupVariable :: !(EnvName -> IO (Maybe Text)), readSecretFile :: !(FilePath -> IO (Either Text Text))} deriving stock (Generic)++-- | Resolved proposal and run options. It may contain real secrets and has no Show+-- instance; pass it to the runner rather than logging it.+data ResolvedRequest = ResolvedRequest {role :: !RoleLabel, command :: !CheckCommand, inputs :: !ProposedInputs, options :: !PreflightOptions} deriving stock (Generic, Eq)++-- | Resolve against the harness process and working directory. Secret files use+-- strict UTF-8 without trimming; read failures become unavailable secret inputs.+processRequestEnvironment :: RequestEnvironment+processRequestEnvironment =+  RequestEnvironment+    (\(EnvName n) -> fmap T.pack <$> Env.lookupEnv (T.unpack n))+    (\p -> tryIO (BS.readFile p) >>= \case Left _ -> pure (Left "secret file could not be read"); Right bytes -> pure (case TE.decodeUtf8' bytes of Left _ -> Left "secret file is not UTF-8"; Right t -> Right t))++-- | Resolve references using the injected harness environment. Missing/unreadable+-- secrets remain unavailable inputs; public values and fake stand-ins retain their+-- classification. This neither runs the application nor proves complete coverage.+resolveRequest :: RequestEnvironment -> PreflightRequest -> IO ResolvedRequest+resolveRequest env r = do+  es <- traverse (\e -> (EnvName (e ^. #name),) <$> resolve (e ^. #form)) (r ^. #environment)+  fs <- traverse (\e -> ProposedFile (FileName (e ^. #name)) <$> resolve (e ^. #form)) (r ^. #files)+  pure (ResolvedRequest (r ^. #role) (r ^. #command) (ProposedInputs es fs (maybe defaultInherited id (r ^. #inherit)) (r ^. #notes)) (PreflightOptions (r ^. #timeoutSeconds) (r ^. #exitCodes)))+  where+    resolve = \case+      InlineValue t -> pure (SuppliedValue Public t)+      SyntheticEntry sensitivity t reason -> pure (SyntheticValue sensitivity t reason)+      UnresolvedEntry t -> pure (UnresolvedValue t)+      UnavailableSecretEntry t -> pure (UnavailableSecret t)+      SecretFromEnvironment n -> do+        found <- if validEnvironmentName n then tryIO ((env ^. #lookupVariable) n) else pure (Right Nothing)+        pure $ case found of Right (Just t) -> SuppliedValue Secret t; _ -> UnavailableSecret ("harness environment variable " <> envText n <> " is not set")+      SecretFromFile path -> do+        found <- tryIO ((env ^. #readSecretFile) path)+        pure $ case found of Right (Right t) -> SuppliedValue Secret t; _ -> UnavailableSecret ("secret file " <> T.pack path <> " could not be read")
+ test/Main.hs view
@@ -0,0 +1,12 @@+module Main (main) where++import Settei.Preflight.Fixture (fixtureMain)+import Settei.Preflight.RequestTest qualified as Request+import Settei.PreflightTest (tests)+import System.Environment (lookupEnv)+import Test.Tasty (defaultMain, testGroup)++main :: IO ()+main = do+  fixture <- lookupEnv "SETTEI_PREFLIGHT_FIXTURE"+  case fixture of Just _ -> fixtureMain; Nothing -> defaultMain (testGroup "preflight" [tests, Request.tests])
+ test/Settei/Preflight/Fixture.hs view
@@ -0,0 +1,41 @@+module Settei.Preflight.Fixture (fixtureMain) where++import Control.Concurrent (threadDelay)+import Control.Monad (forM_, when)+import Data.List (sort)+import Data.Text qualified+import Data.Text.IO qualified as T+import System.Directory (doesFileExist, getCurrentDirectory)+import System.Environment (getArgs, getEnvironment, lookupEnv)+import System.Exit (ExitCode (..), exitWith)+import System.IO (hFlush, stderr, stdout)++fixtureMain :: IO ()+fixtureMain = do+  args <- getArgs+  behaviour <- lookupEnv "FIXTURE_BEHAVIOUR"+  case behaviour of+    Just "reject" -> die 4 "database.url: required value is missing"+    Just "source-failure" -> die 3 "configuration file: could not be read"+    Just "usage" -> die 2 "usage: fixture --check-config"+    Just "crash" -> die 1 "unexpected failure"+    Just "unicode" -> die 4 "runtime.environment: expected one of «dev»"+    Just "sleep" -> getCurrentDirectory >>= putStrLn >> hFlush stdout >> threadDelay 30000000+    Just "chunks" -> do+      putStr "SECRET-"; hFlush stdout; threadDelay 20000; putStrLn "ACROSS-CHUNKS"+    Just "server-like" ->+      if "--check-config" `elem` args+        then putStrLn "configuration valid"+        else do+          marker <- lookupEnv "FIXTURE_STARTUP_MARKER"+          forM_ marker (\path -> writeFile path "started")+          putStrLn "ready"+    Just "echo" -> do+      env <- getEnvironment+      forM_ (sort env) (\(n, v) -> putStrLn (n <> "=" <> v))+      forM_ args (\a -> T.hPutStrLn stderr (fromString a))+      forM_ args (\a -> do exists <- doesFileExist a; when exists (readFile a >>= putStr))+    _ -> putStrLn "configuration valid"+  where+    die n message = T.hPutStrLn stderr message >> exitWith (ExitFailure n)+    fromString = Data.Text.pack
+ test/Settei/Preflight/RequestTest.hs view
@@ -0,0 +1,168 @@+module Settei.Preflight.RequestTest (tests) where++import Control.Exception (bracket)+import Control.Monad (forM_)+import Data.Aeson qualified as A+import Data.Aeson.KeyMap qualified as KM+import Data.ByteString qualified as BS+import Data.ByteString.Lazy qualified as BL+import Data.Generics.Labels ()+import Data.Text qualified as T+import Data.Text.Encoding qualified as TE+import Data.Text.IO qualified as TIO+import Paths_settei_preflight qualified as Paths+import Settei (Sensitivity (..))+import Settei.Env (EnvName (..))+import Settei.Preflight+import Settei.Preflight.Request+import Settei.Prelude hiding (set)+import System.Environment qualified as Env+import System.Exit (ExitCode (..))+import System.FilePath ((</>))+import System.IO.Temp (withSystemTempDirectory)+import System.Process (readProcessWithExitCode)+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit++parse :: BS.ByteString -> IO PreflightRequest+parse bytes = case parsePreflightRequest bytes of Left e -> assertFailure (T.unpack (renderRequestErrorsText e)); Right r -> pure r++fixtureRequest :: Text -> IO PreflightRequest+fixtureRequest behaviour = do+  exe <- Env.getExecutablePath+  pure (preflightRequest (RoleLabel "request-role") (CheckCommand exe []) [RequestEntry "SETTEI_PREFLIGHT_FIXTURE" (InlineValue "1"), RequestEntry "FIXTURE_BEHAVIOUR" (InlineValue behaviour)] [])++runRequest :: PreflightRequest -> [String] -> IO (ExitCode, Text, Text)+runRequest request args = withSystemTempDirectory "request-cli" $ \root -> do+  let path = root </> "request.json"+  TIO.writeFile path (renderPreflightRequestJson request)+  (code, out, err) <- readProcessWithExitCode "settei-preflight" (["--request", path] <> args) ""+  pure (code, T.pack out, T.pack err)++withEnv :: String -> String -> IO a -> IO a+withEnv n v action = bracket (Env.lookupEnv n <* Env.setEnv n v) (maybe (Env.unsetEnv n) (Env.setEnv n)) (const action)++tests :: TestTree+tests =+  testGroup+    "Settei.Preflight.Request"+    [ testCase "request golden covers every form, round-trips, and renders identically" $ do+        bytes <- Paths.getDataFileName "test/golden/request.json" >>= BS.readFile+        r <- parse bytes+        renderPreflightRequestJson r @?= TE.decodeUtf8 bytes+        reparsed <- parse (TE.encodeUtf8 (renderPreflightRequestJson r))+        assertBool "round-trip changed request" (r == reparsed)+        resolved <- resolveRequest (RequestEnvironment (\(EnvName n) -> pure (if n == "PROVISIONED" then Just "SECRET-REFERENCE-SENTINEL" else Nothing)) (\p -> pure (if p == "available" then Right "FILE-SENTINEL\n" else Left "PRIVATE-ERROR"))) r+        let values = fmap snd (resolved ^. #inputs . #environment)+        assertBool "supplied public missing" (SuppliedValue Public "production" `elem` values)+        assertBool "supplied secret missing" (SuppliedValue Secret "SECRET-REFERENCE-SENTINEL" `elem` values)+        assertBool "synthetic secret missing" (SyntheticValue Secret "fake-credential" "test only" `elem` values)+        assertBool "unresolved missing" (UnresolvedValue "resource output" `elem` values)+        assertBool "unavailable reference missing" (UnavailableSecret "harness environment variable MISSING is not set" `elem` values)+        assertBool "file bytes changed" (ProposedFile (FileName "secrets/token") (SuppliedValue Secret "FILE-SENTINEL\n") `elem` (resolved ^. #inputs . #files))+        resolved ^. #inputs . #notes @?= ["unsupported input"],+      testCase "omitted optional fields retain documented defaults" $ do+        r <- parse "{\"schemaVersion\":1,\"type\":\"settei.preflight-request\",\"role\":\"server\",\"command\":{\"executable\":\"/bin/true\",\"arguments\":[]},\"environment\":[]}"+        resolved <- resolveRequest processRequestEnvironment r+        resolved ^. #inputs . #inherited @?= defaultInherited+        resolved ^. #options @?= defaultPreflightOptions,+      testCase "secret files are strict UTF-8, preserve newlines, and hide IO details" $ withSystemTempDirectory "secret-files" $ \root -> do+        let good = root </> "good"; invalid = root </> "invalid"+        BS.writeFile good "exact\n\n"+        BS.writeFile invalid (BS.pack [255, 254])+        r <- fixtureRequest "accept"+        resolved <- resolveRequest processRequestEnvironment (r & #files .~ [RequestEntry "good" (SecretFromFile good), RequestEntry "invalid" (SecretFromFile invalid), RequestEntry "missing" (SecretFromFile (root </> "missing"))])+        case resolved ^. #inputs . #files of+          [ProposedFile _ (SuppliedValue Secret t), ProposedFile _ (UnavailableSecret _), ProposedFile _ (UnavailableSecret _)] -> t @?= "exact\n\n"+          _ -> assertFailure "incorrect secret-file resolution",+      testGroup+        "strict schema and secret-safe diagnostics"+        [ testCase label $ do+            let bytes = BL.toStrict (A.encode (mutation baseDocument))+            case parsePreflightRequest bytes of+              Left ps -> do+                let message = renderRequestErrorsText ps+                assertBool (T.unpack message) (path `T.isInfixOf` message)+                assertBool "parser leaked secret input" (not (sentinel `T.isInfixOf` message))+              Right _ -> assertFailure "invalid request accepted"+        | (label, path, mutation) <- malformedCases+        ],+      testCase "syntax errors never retain Aeson diagnostics or input text" $ do+        let bytes = "{\"schemaVersion\":1,\"SECRET-PARSER-SENTINEL\": INVALID}"+        case parsePreflightRequest bytes of Left ps -> renderRequestErrorsText ps @?= "request: invalid JSON\n"; Right _ -> assertFailure "bad syntax accepted",+      testGroup+        "executable verdict mapping"+        [ testCase label $ do+            r <- fixtureRequest behaviour+            (code, out, err) <- runRequest (modify r) args+            code @?= expected+            assertBool "expected verdict missing" (verdict `T.isInfixOf` out)+            err @?= ""+        | (label, behaviour, modify, args, expected, verdict) <-+            [ ("pass", "accept", id, [], ExitSuccess, "pass"),+              ("inconclusive", "accept", \r -> r & #environment %~ (<> [RequestEntry "GENERATED" (SyntheticEntry Public "fake" "not provisioned")]), [], ExitFailure 1, "inconclusive"),+              ("rejected", "reject", id, [], ExitFailure 4, "fail"),+              ("missing executable", "accept", \r -> r & #command .~ CheckCommand "/definitely/missing" [], [], ExitFailure 3, "executable not found"),+              ("timeout", "sleep", \r -> r & #timeoutSeconds .~ 1, [], ExitFailure 3, "timed out"),+              ("json", "accept", id, ["--json"], ExitSuccess, "{\"schemaVersion\":1,\"type\":\"settei.preflight\"")+            ]+        ],+      testCase "missing and malformed request files exit 3 with safe stderr" $ do+        (code, _, err) <- readProcessWithExitCode "settei-preflight" ["--request", "/definitely/missing"] ""+        code @?= ExitFailure 3+        err @?= "request: file could not be read\n"+        withSystemTempDirectory "malformed-cli" $ \root -> do+          let path = root </> "bad.json"+          writeFile path "{\"SECRET-PARSER-SENTINEL\": invalid}"+          (badCode, out, badErr) <- readProcessWithExitCode "settei-preflight" ["--request", path] ""+          badCode @?= ExitFailure 3+          out @?= ""+          badErr @?= "request: invalid JSON\n",+      testCase "usage and grouped help" $ do+        (code, _, err) <- readProcessWithExitCode "settei-preflight" [] ""+        code @?= ExitFailure 2+        assertBool "usage missing" ("Usage:" `T.isInfixOf` T.pack err)+        (_, out, _) <- readProcessWithExitCode "settei-preflight" ["--help"] ""+        forM_ ["\nPreflight\n", "\nOutput\n"] (\heading -> assertBool "option group missing" (heading `T.isInfixOf` T.pack out)),+      testCase "real secret references never enter request, CLI reports, or invalid summaries" $ withEnv "EP28_PROVISIONED_SECRET" "CLI-SECRET-SENTINEL" $ do+        r <- fixtureRequest "echo"+        let secretRequest = r & #environment %~ (<> [RequestEntry "DATABASE_URL" (SecretFromEnvironment (EnvName "EP28_PROVISIONED_SECRET"))])+        assertBool "secret entered request" (not ("CLI-SECRET-SENTINEL" `T.isInfixOf` renderPreflightRequestJson secretRequest))+        forM_ [[], ["--json"]] $ \args -> do+          (code, out, err) <- runRequest secretRequest args+          code @?= ExitSuccess+          assertBool "secret leaked from CLI" (not ("CLI-SECRET-SENTINEL" `T.isInfixOf` (out <> err)))+          (invalidCode, invalidOut, invalidErr) <- runRequest (secretRequest & #command . #arguments .~ [literalArgument "CLI-SECRET-SENTINEL"]) args+          invalidCode @?= ExitFailure 3+          assertBool "invalid command leaked" (not ("CLI-SECRET-SENTINEL" `T.isInfixOf` (invalidOut <> invalidErr)))+    ]++sentinel :: Text+sentinel = "SECRET-PARSER-SENTINEL"++baseDocument :: A.Value+baseDocument = A.object ["schemaVersion" A..= (1 :: Int), "type" A..= ("settei.preflight-request" :: Text), "role" A..= ("server" :: Text), "command" A..= A.object ["executable" A..= ("/bin/true" :: Text), "arguments" A..= ([] :: [A.Value])], "environment" A..= [A.object ["name" A..= ("TOKEN" :: Text), "value" A..= sentinel]]]++set :: A.Key -> A.Value -> A.Value -> A.Value+set k v (A.Object o) = A.Object (KM.insert k v o)+set _ _ v = v++entry :: [(A.Key, A.Value)] -> A.Value -> A.Value+entry pairs = set "environment" (A.toJSON [A.Object (KM.fromList (("name", A.String "TOKEN") : pairs))])++malformedCases :: [(String, Text, A.Value -> A.Value)]+malformedCases =+  [ ("inline secret", "environment[0]", entry [("value", A.String sentinel), ("sensitivity", A.String "secret")]),+    ("multiple forms", "environment[0]", entry [("value", A.String sentinel), ("unresolved", A.String sentinel)]),+    ("no form", "environment[0]", entry []),+    ("unknown top key", "request", set "SECRET-PARSER-SENTINEL" (A.String sentinel)),+    ("unknown entry key", "environment[0]", entry [("value", A.String sentinel), ("SECRET-PARSER-SENTINEL", A.String sentinel)]),+    ("unknown command key", "command", set "command" (A.object ["SECRET-PARSER-SENTINEL" A..= sentinel])),+    ("wrong type", "request.type", set "type" (A.String sentinel)),+    ("wrong version", "schemaVersion", set "schemaVersion" (A.toJSON (2 :: Int))),+    ("timeout zero", "timeoutSeconds", set "timeoutSeconds" (A.toJSON (0 :: Int))),+    ("bad exit mapping", "exitCodes", set "exitCodes" (A.object ["accepted" A..= (0 :: Int), "usage" A..= [0 :: Int], "sourceFailure" A..= ([] :: [Int]), "rejected" A..= [4 :: Int]])),+    ("bad secret reference name", "secretFromEnvironment", entry [("secretFromEnvironment", A.String sentinel)]),+    ("missing synthetic reason", "reason", entry [("synthetic", A.String sentinel)]),+    ("wrong value type", "value", entry [("value", A.toJSON (4 :: Int))])+  ]
+ test/Settei/PreflightTest.hs view
@@ -0,0 +1,305 @@+module Settei.PreflightTest (tests) where++import Control.Concurrent (forkFinally, killThread, newEmptyMVar, putMVar, readMVar, tryPutMVar)+import Control.Exception (bracket, finally)+import Control.Monad (forM_)+import Data.ByteString qualified as BS+import Data.Generics.Labels ()+import Data.IORef (newIORef, readIORef, writeIORef)+import Data.List.NonEmpty qualified as NE+import Data.Text qualified as T+import Data.Text.IO qualified as TIO+import Paths_settei_preflight qualified as Paths+import Settei (Sensitivity (..))+import Settei.Env (EnvName (..))+import Settei.Preflight.Internal+import Settei.Prelude hiding (simple)+import System.Directory qualified as D+import System.Environment qualified as Env+import System.Exit (ExitCode (..))+import System.FilePath ((</>))+import System.IO qualified as IO+import System.IO.Temp (withSystemTempDirectory)+import System.Process qualified as Process+import System.Timeout (timeout)+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit++fixture :: Text -> [Argument] -> (ProposedInputs -> ProposedInputs) -> IO PreflightReport+fixture behaviour args modify = do+  exe <- Env.getExecutablePath+  runPreflight defaultPreflightOptions (RoleLabel "server") (CheckCommand exe args) (modify (fixtureInputs behaviour))++fixtureInputs :: Text -> ProposedInputs+fixtureInputs behaviour = emptyProposedInputs & #environment .~ [(EnvName "SETTEI_PREFLIGHT_FIXTURE", SuppliedValue Public "1"), (EnvName "FIXTURE_BEHAVIOUR", SuppliedValue Public behaviour)]++withEnv :: String -> String -> IO a -> IO a+withEnv n v action = bracket (Env.lookupEnv n <* Env.setEnv n v) (maybe (Env.unsetEnv n) (Env.setEnv n)) (const action)++noLeak :: Text -> PreflightReport -> Assertion+noLeak sentinel r = forM_ [renderPreflightReportText r, renderPreflightReportJson r, tshow r] $ \s -> assertBool "secret leaked" (not (sentinel `T.isInfixOf` s))++stdoutOf :: PreflightReport -> Text+stdoutOf = maybe "" (view #standardOutput) . view #execution++stderrOf :: PreflightReport -> Text+stderrOf = maybe "" (view #standardError) . view #execution++tests :: TestTree+tests =+  testGroup+    "Settei.Preflight"+    [ testCase "accept with supplied inputs passes" $ do+        r <- fixture "accept" [] id+        r ^. #outcome @?= Accepted+        r ^. #coverage @?= CompleteCoverage+        r ^. #verdict @?= Pass+        fmap (view #exitCode) (r ^. #execution) @?= Just (Just 0)+        stdoutOf r @?= "configuration valid\n",+      testGroup+        "exit mapping"+        [ testCase (T.unpack b) $ do+            r <- fixture b [] id+            r ^. #outcome @?= Rejected c n+            r ^. #verdict @?= Fail+        | (b, c, n) <- [("reject", ConfigurationRejected, 4), ("source-failure", SourceFailure, 3), ("usage", UsageError, 2), ("crash", UnknownExitCode, 1)]+        ],+      testGroup+        "executable lookup"+        [ testCase exe $ do+            r <- runPreflight defaultPreflightOptions (RoleLabel "worker") (CheckCommand exe []) emptyProposedInputs+            r ^. #outcome @?= NotRun (ExecutableNotFound exe)+            r ^. #role @?= RoleLabel "worker"+            r ^. #execution @?= Nothing+        | exe <- ["/definitely/missing", "settei-no-such-executable-ep28"]+        ],+      testCase "relative executable is resolved before changing cwd" $ do+        exe <- Env.getExecutablePath+        cwd <- D.getCurrentDirectory+        -- A relative symlink in a temporary directory still resolves from the harness cwd.+        withSystemTempDirectory "preflight-relative" $ \root -> do+          let link = root </> "fixture"+          D.createFileLink exe link+          relative <- D.makeRelativeToCurrentDirectory link+          r <- runPreflight defaultPreflightOptions (RoleLabel "server") (CheckCommand (if '/' `elem` relative then relative else "./" <> relative) []) (fixtureInputs "accept")+          r ^. #verdict @?= Pass+          D.getCurrentDirectory >>= (@?= cwd),+      testCase "nonexecutable file is spawn failure" $ withSystemTempDirectory "preflight-spawn" $ \root -> do+        let path = root </> "not-executable"+        writeFile path "not executable"+        r <- runPreflight defaultPreflightOptions (RoleLabel "worker") (CheckCommand path []) emptyProposedInputs+        case r ^. #outcome of NotRun (SpawnFailed _) -> pure (); _ -> assertFailure (show r),+      testCase "timeout retains captured bytes and removes its own directory" $ do+        exe <- Env.getExecutablePath+        r <- runPreflight (defaultPreflightOptions & #timeoutSeconds .~ 1) (RoleLabel "server") (CheckCommand exe []) (fixtureInputs "sleep")+        r ^. #outcome @?= NotRun (TimedOut 1)+        assertBool "cwd missing from timed-out invocation" (not (T.null (stdoutOf r)))+        D.doesDirectoryExist (T.unpack (T.strip (stdoutOf r))) >>= (@?= False),+      testCase "environment is exactly proposed when inherit is empty" $ withEnv "PREFLIGHT_AMBIENT" "must-not-inherit" $ do+        r <- fixture "echo" [] (\p -> p & #inherited .~ [])+        -- CoreFoundation may synthesize this variable inside a macOS GHC process.+        filter (not . T.isPrefixOf "__CF_USER_TEXT_ENCODING=") (T.lines (stdoutOf r)) @?= ["FIXTURE_BEHAVIOUR=echo", "SETTEI_PREFLIGHT_FIXTURE=1"]+        native <- runPreflight defaultPreflightOptions (RoleLabel "native") (CheckCommand "/usr/bin/env" []) (emptyProposedInputs & #inherited .~ [] & #environment .~ [(EnvName "ONLY", SuppliedValue Public "proposed")])+        stdoutOf native @?= "ONLY=proposed\n",+      testCase "explicit allowlist reports absent names" $ do+        r <- fixture "echo" [] (\p -> p & #inherited .~ [EnvName "PATH", EnvName "PREFLIGHT_MISSING_EP28"])+        assertBool "PATH missing" ("PATH=" `T.isInfixOf` stdoutOf r)+        r ^. #inherited @?= [(EnvName "PATH", True), (EnvName "PREFLIGHT_MISSING_EP28", False)],+      testCase "environment and file secrets are redacted in every report form" $ do+        r <- fixture "echo" [fileArgument (FileName "secrets/token")] $ \p ->+          p+            & #environment+            %~ (<> [(EnvName "DATABASE_URL", SuppliedValue Secret "ENV-SECRET-SENTINEL")])+            & #files+            .~ [ProposedFile (FileName "secrets/token") (SuppliedValue Secret "FILE-SECRET-SENTINEL")]+        noLeak "ENV-SECRET-SENTINEL" r+        noLeak "FILE-SECRET-SENTINEL" r+        assertBool "environment marker" ("[redacted:DATABASE_URL]" `T.isInfixOf` stdoutOf r)+        assertBool "file marker" ("[redacted:file secrets/token]" `T.isInfixOf` stdoutOf r),+      testCase "materialized compound file and directory arguments retain symbolic summary" $ do+        let name = FileName "etc/application.yaml"+        r <- fixture "echo" [Argument [LiteralPart "yaml:", FilePart name], Argument [DirectoryPart (FileName "etc")], fileArgument name] $ \p -> p & #files .~ [ProposedFile name (SuppliedValue Public "server: example\n")]+        assertBool "content not passed" ("server: example\n" `T.isInfixOf` stdoutOf r)+        assertBool "compound path missing" ("yaml:/" `T.isInfixOf` stderrOf r)+        r ^. #command . #arguments @?= ["yaml:{file:etc/application.yaml}", "{dir:etc}", "{file:etc/application.yaml}"],+      testCase "omitted file absent but directory exists" $ do+        r <- fixture "echo" [fileArgument (FileName "etc/missing"), Argument [DirectoryPart (FileName "etc")]] $ \p -> p & #files .~ [ProposedFile (FileName "etc/missing") (UnresolvedValue "not yet created")]+        r ^. #verdict @?= Inconclusive+        assertBool "omitted file passed" (not (any (\s -> s ^. #input == FileInput (FileName "etc/missing") && s ^. #passed) (r ^. #inputs))),+      testCase "synthetic secret is partial and redacted" $ do+        r <- fixture "echo" [] $ \p -> p & #environment %~ (<> [(EnvName "TOKEN", SyntheticValue Secret "FAKE-CREDENTIAL" "stand-in")])+        r ^. #verdict @?= Inconclusive+        length (limitations (r ^. #coverage)) @?= 1+        noLeak "FAKE-CREDENTIAL" r,+      testGroup+        "omissions stay visible"+        [ testCase (T.unpack b) $ do+            r <- fixture b [] $ \p -> p & #environment %~ (<> [(EnvName "GENERATED", UnresolvedValue "resource output"), (EnvName "TOKEN", UnavailableSecret "not provisioned")])+            r ^. #verdict @?= expected+            length (limitations (r ^. #coverage)) @?= 2+            length (filter (not . view #passed) (r ^. #inputs)) @?= 2+        | (b, expected) <- [("accept", Inconclusive), ("reject", Fail)]+        ],+      testCase "an extraction note alone prevents pass" $ do+        r <- fixture "accept" [] (\p -> p & #notes .~ ["unsupported projected volume"])+        r ^. #verdict @?= Inconclusive+        fmap (view #kind) (limitations (r ^. #coverage)) @?= [ExtractionIncomplete]+        assertBool "honest explanation missing" ("not a deployment validation" `T.isInfixOf` renderPreflightReportJson r),+      testCase "secret is redacted across flushed chunks" $ do+        r <- fixture "chunks" [] (\p -> p & #environment %~ (<> [(EnvName "TOKEN", SuppliedValue Secret "SECRET-ACROSS-CHUNKS")]))+        stdoutOf r @?= "[redacted:TOKEN]\n",+      testCase "redaction uses longest original match and never rescans markers" $ do+        redact [("LONG-SECRET", "[redacted:first]"), ("SECRET", "[redacted:second]"), ("first", "[redacted:third]")] "LONG-SECRET SECRET" @?= "[redacted:first] [redacted:second]",+      testCase "secret-bearing names use numeric markers" $ do+        r <- fixture "echo" [] (\p -> p & #environment %~ (<> [(EnvName "NAME_SENTINEL", SuppliedValue Secret "SENTINEL")]))+        noLeak "SENTINEL" r+        assertBool "numeric marker missing" ("[redacted:input 2]" `T.isInfixOf` stdoutOf r),+      testCase "unicode diagnostic survives empty default locale context" $ do+        r <- fixture "unicode" [] id+        stderrOf r @?= "runtime.environment: expected one of «dev»\n",+      testCase "server-like check skips startup and non-check control writes marker" $ withSystemTempDirectory "preflight-marker" $ \root -> do+        let marker = root </> "started"+            p x = x & #environment %~ (<> [(EnvName "FIXTURE_STARTUP_MARKER", SuppliedValue Public (T.pack marker))])+        checked <- fixture "server-like" [literalArgument "--check-config"] p+        checked ^. #verdict @?= Pass+        D.doesFileExist marker >>= (@?= False)+        control <- fixture "server-like" [] p+        control ^. #verdict @?= Pass+        D.doesFileExist marker >>= (@?= True),+      testGroup+        "injectable IO failures"+        [ testCase label $ do+            exe <- Env.getExecutablePath+            r <- runPreflightWith operations defaultPreflightOptions (RoleLabel "worker") (CheckCommand exe []) (fixtureInputs "accept")+            r ^. #verdict @?= Fail+            assertBool (show r) (predicate (r ^. #outcome))+            assertBool "upstream exception leaked" (not ("PRIVATE-EXCEPTION" `T.isInfixOf` tshow r))+            if label == "materialization" then assertBool "pre-spawn input marked passed" (all (not . view #passed) (r ^. #inputs)) else pure ()+        | (label, operations, predicate) <-+            [ ("materialization", defaultRunOperations & #materializeInputs .~ (\_ _ _ -> ioError (userError "PRIVATE-EXCEPTION")), \case NotRun (MaterializationFailed _) -> True; _ -> False),+              ("capture", defaultRunOperations & #captureOutput .~ (\_ -> ioError (userError "PRIVATE-EXCEPTION")), \case NotRun (CaptureFailed _) -> True; _ -> False),+              ("cleanup", defaultRunOperations & #removeInputs .~ (\_ -> pure ()), \case NotRun (CleanupFailed _) -> True; _ -> False)+            ]+        ],+      testCase "timeout includes draining after child exit and explicitly reaps pipe retainer" $ do+        exe <- Env.getExecutablePath+        retained <- newIORef Nothing+        checked <- newIORef Nothing+        let create process = do+              (reader, writer) <- Process.createPipe+              (mi, _, me, ph) <- Process.createProcess_ "preflight-pipe-test" (process {Process.std_out = Process.UseHandle writer})+              writeIORef checked (Just ph)+              (_, _, _, retainer) <- Process.createProcess_ "preflight-pipe-retainer" ((Process.proc "/bin/sleep" ["30"]) {Process.std_in = Process.NoStream, Process.std_out = Process.UseHandle writer, Process.std_err = Process.NoStream})+              writeIORef retained (Just retainer)+              IO.hClose writer+              pure (mi, Just reader, me, ph)+            reap = readIORef retained >>= mapM_ (\ph -> Process.terminateProcess ph >> Process.waitForProcess ph >> pure ())+        report <- runPreflightWith (defaultRunOperations & #createCheckProcess .~ create) (defaultPreflightOptions & #timeoutSeconds .~ 1) (RoleLabel "server") (CheckCommand exe []) (fixtureInputs "accept") `finally` reap+        report ^. #outcome @?= NotRun (TimedOut 1)+        child <- readIORef checked >>= maybe (fail "child not spawned") pure+        Process.getProcessExitCode child >>= (@?= Just ExitSuccess)+        retainer <- readIORef retained >>= maybe (fail "retainer not spawned") pure+        status <- Process.getProcessExitCode retainer+        assertBool "retainer was not reaped" (status /= Nothing),+      testCase "asynchronous cancellation joins readers and removes private files" $ do+        exe <- Env.getExecutablePath+        rootReady <- newEmptyMVar+        reading <- newEmptyMVar+        done <- newEmptyMVar+        let operations =+              defaultRunOperations+                & #materializeInputs+                .~ (\root p cmd -> materialize root p cmd >> putMVar rootReady root)+                & #captureOutput+                .~ (\h -> do chunk <- BS.hGetSome h 32768; if BS.null chunk then pure () else tryPutMVar reading () >> pure (); pure chunk)+        tid <- forkFinally (runPreflightWith operations defaultPreflightOptions (RoleLabel "server") (CheckCommand exe []) (fixtureInputs "sleep")) (putMVar done)+        root <- readMVar rootReady+        readMVar reading+        killThread tid+        completed <- timeout 5000000 (readMVar done)+        assertBool "cancellation deadlocked" (case completed of Just (Left _) -> True; _ -> False)+        D.doesPathExist root >>= (@?= False),+      testCase "all retained metadata and omission reasons are redacted" $ do+        exe <- Env.getExecutablePath+        let secret = "META_SENTINEL"+            inputs =+              fixtureInputs "echo"+                & #environment+                %~ (<> [(EnvName "X_META_SENTINEL", SuppliedValue Secret secret), (EnvName "UNRESOLVED", UnresolvedValue ("missing " <> secret)), (EnvName "SYNTHETIC", SyntheticValue Public "dummy" ("fake " <> secret))])+                & #files+                .~ [ProposedFile (FileName "files/META_SENTINEL") (UnavailableSecret ("file " <> secret))]+                & #notes+                .~ ["unsupported " <> secret]+        report <- runPreflight defaultPreflightOptions (RoleLabel ("role " <> secret)) (CheckCommand exe []) inputs+        report ^. #verdict @?= Inconclusive+        noLeak secret report,+      testCase "problem renderers escape invalid control characters on one line" $ do+        let problem = InvalidEnvironmentName (EnvName "BAD\nNAME\r\t\0")+        renderPreflightProblemText problem @?= "invalid-environment-name: BAD\\nNAME\\r\\t\\0"+        length (T.lines (renderPreflightProblemsText (problem :| []))) @?= 1,+      testGroup "validation" validationTests,+      testGroup+        "report goldens"+        [ testCase name $ do+            expected <- Paths.getDataFileName ("test/golden/" <> name) >>= TIO.readFile+            actual @?= expected+            actual @?= renderer report+        | (base, report) <- [("fail", goldenFail), ("inconclusive", goldenInconclusive)],+          (ext, renderer) <- [("txt", renderPreflightReportText), ("json", renderPreflightReportJson)],+          let name = "report-" <> base <> "." <> ext,+          let actual = renderer report+        ]+    ]++validationTests :: [TestTree]+validationTests =+  [ testCase label $ do+      let result = validatePreflight chosenOptions chosenRole chosenCommand chosenInputs+      case result of+        Left ps -> assertBool (T.unpack (renderPreflightProblemsText ps)) (any ((== kind) . (\x -> let (k, _, _, _) = problemFields x in k)) (NE.toList ps))+        Right () -> assertFailure "invalid request accepted"+      r <- runPreflight chosenOptions chosenRole chosenCommand chosenInputs+      r ^. #execution @?= Nothing+      assertBool "pre-spawn input marked passed" (all (not . view #passed) (r ^. #inputs))+      noLeak sentinel r+      case result of Left ps -> assertBool "pure validation leaked" (not (sentinel `T.isInfixOf` tshow ps)); _ -> pure ()+  | (label, kind, chosenOptions, chosenRole, chosenCommand, chosenInputs) <- cases+  ]+  where+    sentinel = "SECRET-SENTINEL"+    opts = defaultPreflightOptions+    role = RoleLabel "server"+    cmd = CheckCommand "/bin/echo" []+    p = emptyProposedInputs & #environment .~ [(EnvName "TOKEN", SuppliedValue Secret sentinel)]+    env n v = p & #environment %~ (<> [(EnvName n, v)])+    file n = ProposedFile (FileName n) (SuppliedValue Public "contents")+    c label kind opt rol command input = (label, kind, opt, rol, command, input)+    simple label kind input = c label kind opts role cmd input+    cases =+      [ simple "invalid name" "invalid-environment-name" (env "bad-name-SECRET-SENTINEL" (SuppliedValue Public "x")),+        simple "duplicate env" "duplicate-environment-name" (env "TOKEN" (SuppliedValue Public "x")),+        simple "NUL value" "invalid-environment-value" (env "BAD" (SuppliedValue Public "x\0")),+        simple "invalid file" "invalid-file-name" (p & #files .~ [file "../SECRET-SENTINEL"]),+        simple "duplicate file" "duplicate-file-name" (p & #files .~ [file "a", file "a"]),+        simple "file parent collision" "file-path-conflict" (p & #files .~ [file "a/b", file "a"]),+        simple "inherited invalid" "invalid-inherited-name" (p & #inherited .~ [EnvName "bad-name"]),+        simple "inherited duplicate" "duplicate-inherited-name" (p & #inherited .~ [EnvName "PATH", EnvName "PATH"]),+        simple "inherited proposed" "inherited-name-proposed" (p & #inherited .~ [EnvName "TOKEN"]),+        c "unknown file" "unknown-file-reference" opts role (cmd & #arguments .~ [fileArgument (FileName "missing")]) p,+        c "unknown dir" "unknown-file-reference" opts role (cmd & #arguments .~ [Argument [DirectoryPart (FileName "missing")]]) p,+        c "secret argument" "secret-in-argument" opts role (cmd & #arguments .~ [literalArgument sentinel]) p,+        c "split secret argument" "secret-in-argument" opts role (cmd & #arguments .~ [Argument [LiteralPart "SECRET-", LiteralPart "SENTINEL"]]) p,+        c "secret executable" "secret-in-executable" opts role (CheckCommand (T.unpack sentinel) []) p,+        c "empty executable" "invalid-executable" opts role (CheckCommand "" []) p,+        c "NUL argument" "invalid-argument" opts role (cmd & #arguments .~ [literalArgument "\0"]) p,+        c "invalid timeout" "invalid-timeout" (opts & #timeoutSeconds .~ 0) role cmd p,+        c "overflow timeout" "invalid-timeout" (opts & #timeoutSeconds .~ maxBound) role cmd p,+        c "overlapping codes" "invalid-exit-code-mapping" (opts & #exitCodes .~ ExitCodeMapping 0 [0] [] []) role cmd p,+        c "out of range code" "invalid-exit-code-mapping" (opts & #exitCodes .~ ExitCodeMapping 256 [] [] []) role cmd p,+        c "empty role" "empty-role-label" opts (RoleLabel " ") cmd p+      ]+        <> [simple ("invalid path " <> show n) "invalid-file-name" (p & #files .~ [file n]) | n <- ["", "/absolute", "a//b", "a/./b", "a/../b", "a\\b", "a\0b"]]++goldenFail, goldenInconclusive :: PreflightReport+goldenFail = makeReport defaultPreflightOptions (RoleLabel "server") "/fixture" [] (emptyProposedInputs & #inherited .~ []) [] True (Right 4, Just (CapturedExecution (Just 4) False "" "database.url: required value is missing\n"))+goldenInconclusive = makeReport defaultPreflightOptions (RoleLabel "worker") "/fixture" [literalArgument "--check-config"] (emptyProposedInputs & #inherited .~ [] & #environment .~ [(EnvName "POD_NAMESPACE", SyntheticValue Public "production" "downward API namespace")]) [] True (Right 0, Just (CapturedExecution (Just 0) False "configuration valid\n" ""))
+ test/golden/report-fail.json view
@@ -0,0 +1,1 @@+{"schemaVersion":1,"type":"settei.preflight","role":"server","verdict":"fail","verdictExplanation":"configuration rejected, exit 4","outcome":{"kind":"rejected","category":"configuration-rejected","exitCode":4},"coverage":{"kind":"complete","limitations":[]},"inputs":[],"inherited":[],"notes":[],"command":{"executable":"/fixture","arguments":[]},"execution":{"exitCode":4,"timedOut":false,"stdout":"","stderr":"database.url: required value is missing\n"}}
+ test/golden/report-fail.txt view
@@ -0,0 +1,9 @@+preflight server: fail (configuration rejected, exit 4)+coverage: complete+inputs: (none)+inherited: (none)+command: /fixture+stdout:+  (empty)+stderr:+  database.url: required value is missing
+ test/golden/report-inconclusive.json view
@@ -0,0 +1,1 @@+{"schemaVersion":1,"type":"settei.preflight","role":"worker","verdict":"inconclusive","verdictExplanation":"accepted with partial coverage; not a deployment validation","outcome":{"kind":"accepted"},"coverage":{"kind":"partial","limitations":[{"channel":"environment","name":"POD_NAMESPACE","limitation":"synthetic-stand-in","reason":"downward API namespace"}]},"inputs":[{"channel":"environment","name":"POD_NAMESPACE","classification":"synthetic-public","passed":true}],"inherited":[],"notes":[],"command":{"executable":"/fixture","arguments":["--check-config"]},"execution":{"exitCode":0,"timedOut":false,"stdout":"configuration valid\n","stderr":""}}
+ test/golden/report-inconclusive.txt view
@@ -0,0 +1,11 @@+preflight worker: inconclusive (accepted with partial coverage; not a deployment validation)+coverage: partial+  POD_NAMESPACE: synthetic stand-in (downward API namespace)+inputs:+  POD_NAMESPACE: synthetic public+inherited: (none)+command: /fixture --check-config+stdout:+  configuration valid+stderr:+  (empty)
+ test/golden/request.json view
@@ -0,0 +1,1 @@+{"schemaVersion":1,"type":"settei.preflight-request","role":"server","command":{"executable":"/fixture","arguments":[{"literal":"--config"},{"file":"etc/application.yaml"},{"directory":"secrets"},{"parts":[{"literal":"yaml:"},{"file":"etc/application.yaml"}]}]},"environment":[{"name":"ENVIRONMENT","value":"production"},{"name":"TOKEN","secretFromEnvironment":"PROVISIONED"},{"name":"MISSING_TOKEN","secretFromEnvironment":"MISSING"},{"name":"FAKE","synthetic":"fake-credential","reason":"test only","sensitivity":"secret"},{"name":"GENERATED","unresolved":"resource output"},{"name":"UNAVAILABLE","unavailableSecret":"not provisioned"}],"files":[{"name":"etc/application.yaml","value":"server: example\n"},{"name":"secrets/token","secretFromFile":"available"},{"name":"secrets/missing","secretFromFile":"missing"}],"inherit":[],"timeoutSeconds":10,"exitCodes":{"accepted":0,"usage":[2],"sourceFailure":[3],"rejected":[4]},"notes":["unsupported input"]}