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 +15/−0
- LICENSE +28/−0
- README.md +31/−0
- app/Main.hs +37/−0
- settei-preflight.cabal +102/−0
- src/Settei/Preflight.hs +155/−0
- src/Settei/Preflight/Internal.hs +549/−0
- src/Settei/Preflight/Request.hs +265/−0
- test/Main.hs +12/−0
- test/Settei/Preflight/Fixture.hs +41/−0
- test/Settei/Preflight/RequestTest.hs +168/−0
- test/Settei/PreflightTest.hs +305/−0
- test/golden/report-fail.json +1/−0
- test/golden/report-fail.txt +9/−0
- test/golden/report-inconclusive.json +1/−0
- test/golden/report-inconclusive.txt +11/−0
- test/golden/request.json +1/−0
+ 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"]}