ecluse:ecluse-core
Safe HaskellNone
LanguageGHC2021

Ecluse.Core.Package

Description

The package domain model -- ecosystem-agnostic vocabulary for the rules engine.

These types capture everything the proxy needs to reason about a package version while staying decoupled from any registry's wire format. Registry adapters (npm, PyPI, RubyGems) are responsible for projecting their responses into these types; nothing above the registry layer sees registry-specific structures.

Three pieces of this vocabulary earn their own sibling module: the Ecosystem tag lives in Ecluse.Core.Ecosystem (shared with the version engine and the registry adapters), version identity and ordering live in Ecluse.Core.Version (a Version is embedded here in PackageDetails), and the integrity-digest vocabulary (Hash, HashAlg, and the Subresource-Integrity forms) lives in Ecluse.Core.Package.Hash and is re-exported here in full. Import those modules directly when you need to name or build their types.

The design follows two principles synthesised from the protocol research (see docs/research/synthesis.md):

  • Rules consume normalised signals, not raw fields. The risky behaviours differ on the wire (npm install scripts, PyPI sdist builds, RubyGems native extensions) but collapse to one signal -- CodeExecSignal. Trust likewise collapses to Trust. A rule never learns which ecosystem it is looking at.
  • Signal availability is explicit. A signal the adapter has not (or cannot cheaply) determine is CodeExecUnknown / TrustUnknown / Nothing, so a pure rule abstains rather than guessing and the effectful tier can resolve it later (see docs/architecture.md → "Rules Engine").
Synopsis

Scopes

data Scope Source #

An npm scope, stored without its leading '@' (the scope of @myorg/pkg is "myorg"). Construct via mkScope, which normalises away a leading '@' so equality is independent of how the scope was written.

A scope is a bulk-stored, equality-only identifier (an allow-list key and part of PackageName identity), so it is held as ShortText: the Text -> ShortText conversion happens once in mkScope and the reverse once in unScope/renderScope, never in a hot loop (see STYLE.md §6).

Instances

Instances details
Show Scope Source # 
Instance details

Defined in Ecluse.Core.Package

Methods

showsPrec :: Int -> Scope -> ShowS #

show :: Scope -> String #

showList :: [Scope] -> ShowS #

Eq Scope Source # 
Instance details

Defined in Ecluse.Core.Package

Methods

(==) :: Scope -> Scope -> Bool #

(/=) :: Scope -> Scope -> Bool #

Ord Scope Source # 
Instance details

Defined in Ecluse.Core.Package

Methods

compare :: Scope -> Scope -> Ordering #

(<) :: Scope -> Scope -> Bool #

(<=) :: Scope -> Scope -> Bool #

(>) :: Scope -> Scope -> Bool #

(>=) :: Scope -> Scope -> Bool #

max :: Scope -> Scope -> Scope #

min :: Scope -> Scope -> Scope #

mkScope :: Text -> Scope Source #

Build a Scope, tolerating an optional leading '@'.

unScope :: Scope -> Text Source #

The bare scope text, without the leading '@'.

renderScope :: Scope -> Text Source #

Render a scope in npm wire form, with the leading '@'.

Package identity

data PackageName Source #

A package identity, decoupled from any registry's wire format.

Identity differs by ecosystem -- npm has scopes and is case-sensitive, PyPI normalises per PEP 503, RubyGems is verbatim -- so the type is opaque: build it with mkPackageName, which records the ecosystem, computes a pkgCanonical key used for equality/matching, and keeps a pkgDisplay form for faithful rendering. Equality and ordering are on (pkgEcosystem, pkgNamespace, pkgCanonical) only -- never the display or base form -- so Flask and flask are the same PyPI package but different npm ones.

mkPackageName :: Ecosystem -> Maybe Scope -> Text -> PackageName Source #

Build a PackageName, normalising the canonical key for the ecosystem.

The display form is the scope-joined raw name (@scope/name when scoped); the canonical key is that form normalised: PEP 503 lower-casing and [-_.]+- collapsing for PyPI, verbatim for npm and RubyGems.

pkgEcosystem :: PackageName -> Ecosystem Source #

The ecosystem this name belongs to.

pkgNamespace :: PackageName -> Maybe Scope Source #

The scope, if scoped (npm @scope/name). Nothing for PyPI/RubyGems.

pkgCanonical :: PackageName -> ShortText Source #

The normalised key for equality and matching (PEP 503 for PyPI; verbatim for npm/RubyGems). Held as ShortText: it is an equality/Ord key that is normalised once at mkPackageName and never sliced afterwards.

pkgDisplay :: PackageName -> ShortText Source #

The name as published, for rendering and round-tripping. Held as ShortText; read it back as Text through renderPackageName.

pkgBaseName :: PackageName -> ShortText Source #

The unscoped base name: the published name with any @scope/ prefix dropped (@babel/code-framecode-frame). Stored structurally at mkPackageName (it is exactly the bare name the constructor is given), so the npm tarball/path layer and the mirror queue read it as a field rather than re-slicing the display form. Not part of identity (like pkgDisplay); held as ShortText and read back as Text through unscopedName.

renderPackageName :: PackageName -> Text Source #

Render a package name in its native wire form (the display name).

unscopedName :: PackageName -> Text Source #

The unscoped (base) name as Text (@babel/code-framecode-frame): the ShortText pkgBaseName field read back. The single home for the bare-name the npm tarball/path layer and the mirror queue all need; it is stored structurally at mkPackageName rather than reconstructed by rendering then string-stripping the scope.

Normalised signals

data CodeExecSignal Source #

Whether installing a version executes code (the cross-ecosystem unification of npm install scripts, PyPI sdist builds, and RubyGems native extensions).

Constructors

NoCodeOnInstall

Determined: installation runs no code.

RunsCodeOnInstall Text

Determined: installation runs code; the text says how (audit trail).

CodeExecUnknown

Not yet determined (e.g. the RubyGems gemspec has not been fetched). Pure rules abstain; the effectful tier may resolve it.

Instances

Instances details
Show CodeExecSignal Source # 
Instance details

Defined in Ecluse.Core.Package

Eq CodeExecSignal Source # 
Instance details

Defined in Ecluse.Core.Package

data Trust Source #

The trust/provenance signal for a version. The how of trust differs by ecosystem (npm dist.signatures, PyPI PEP 740 attestations, RubyGems signed gems/MFA) but is captured as TrustEvidence so rules stay ecosystem-blind.

Constructors

Trusted (NonEmpty TrustEvidence)

Determined trusted, with the evidence supporting it.

Untrusted

Determined: no trust signal established.

TrustUnknown

Not yet determined (e.g. signature verification needs a fetch).

Instances

Instances details
Show Trust Source # 
Instance details

Defined in Ecluse.Core.Package

Methods

showsPrec :: Int -> Trust -> ShowS #

show :: Trust -> String #

showList :: [Trust] -> ShowS #

Eq Trust Source # 
Instance details

Defined in Ecluse.Core.Package

Methods

(==) :: Trust -> Trust -> Bool #

(/=) :: Trust -> Trust -> Bool #

data TrustEvidence Source #

A normalised reason a version is trusted; the adapter maps its ecosystem's mechanism onto this vocabulary.

Constructors

Signed

The artifact is cryptographically signed.

Attested

The artifact carries a provenance attestation (e.g. Sigstore).

MfaPublished

The version was published under enforced multi-factor auth.

OtherEvidence Text

An ecosystem mechanism not yet in this vocabulary (escape hatch).

Instances

Instances details
Show TrustEvidence Source # 
Instance details

Defined in Ecluse.Core.Package

Eq TrustEvidence Source # 
Instance details

Defined in Ecluse.Core.Package

data Availability Source #

Whether a version is offered, advisory-deprecated, or withdrawn.

Constructors

Available

Offered normally.

Deprecated Text

Advisory deprecation (npm); still resolvable. Carries the message.

Yanked (Maybe Text)

Withdrawn from resolution (PyPI yank keeps the file; RubyGems yank removes it). Carries the reason, if given.

Instances

Instances details
Show Availability Source # 
Instance details

Defined in Ecluse.Core.Package

Eq Availability Source # 
Instance details

Defined in Ecluse.Core.Package

Artifacts

data Artifact Source #

One distribution file for a version. A version owns a NonEmpty list of these: npm has exactly one, PyPI has an sdist plus many wheels, RubyGems has one per platform.

Constructors

Artifact 

Fields

Instances

Instances details
Show Artifact Source # 
Instance details

Defined in Ecluse.Core.Package

Eq Artifact Source # 
Instance details

Defined in Ecluse.Core.Package

data ArtifactKind Source #

What kind of distribution file an artifact is.

Constructors

Tarball

An npm tarball.

Sdist

A PyPI source distribution (building it may execute code).

Wheel Text

A PyPI wheel; carries its compatibility tag (e.g. "cp310-…").

Gem Text

A RubyGems gem; carries its platform ("ruby" = pure).

Instances

Instances details
Show ArtifactKind Source # 
Instance details

Defined in Ecluse.Core.Package

Eq ArtifactKind Source # 
Instance details

Defined in Ecluse.Core.Package

data Hash Source #

An integrity digest of an artifact. Opaque: a Hash is built only through mkHash, which validates that the digest is well-formed, so every value of this type carries the proof that its digest could be a real digest of its algorithm. Read it back through hashAlg and hashValue.

Instances

Instances details
Show Hash Source # 
Instance details

Defined in Ecluse.Core.Package.Hash

Methods

showsPrec :: Int -> Hash -> ShowS #

show :: Hash -> String #

showList :: [Hash] -> ShowS #

Eq Hash Source # 
Instance details

Defined in Ecluse.Core.Package.Hash

Methods

(==) :: Hash -> Hash -> Bool #

(/=) :: Hash -> Hash -> Bool #

hashAlg :: Hash -> HashAlg Source #

The algorithm the digest was computed with.

hashValue :: Hash -> Text Source #

The digest itself, in the algorithm's wire encoding (e.g. hex, or the single sha512-… component for SRI).

mkHash :: HashAlg -> Text -> Either Text Hash Source #

Build a Hash, validating that the digest is structurally well-formed: cleanly encoded and exactly the byte length its algorithm specifies. This is the only way to construct a Hash, so the type itself is the proof that the digest could be a real digest of that algorithm -- an empty, truncated, over-long, non-hex, or bad-base64 value is unconstructable and so can never reach an integrity gate as a degenerate digest (the fail-open this closes is docs/architecture/security.md invariant 5).

Well-formedness is not admissibility: a well-formed but weak SHA-1 digest builds fine; whether it clears the public-integrity floor is the separate decision of Ecluse.Core.Package.Integrity. mkHash rejects a malformed digest, never a merely weak one.

A hex-tagged algorithm (everything but SRI) takes lower- or upper-case hex of the algorithm's digest length. An SRI takes exactly one <alg>-<base64> component, naming a Subresource-Integrity algorithm (sha256, sha384, sha512) whose base64 body decodes to that algorithm's digest length. A wire string that joins several components with whitespace is malformed here: split it with mkSriHashes, which yields one Hash per component, so no reader ever has to decide which component of a joined string a Hash means.

>>> import Ecluse.Core.Package.Hash (HashAlg (SHA1))
>>> fmap hashAlg (mkHash SHA1 "0a4d55a8d778e5022fab701977c5d840bbc486d0")
Right SHA1
>>> mkHash SHA1 "deadbeef"
Left "malformed sha1 digest"

mkSriHashes :: Text -> Either Text (NonEmpty Hash) Source #

Split a Subresource-Integrity wire string -- one or more whitespace-separated <alg>-<base64> components (npm's dist.integrity) -- into one SRI Hash per component, each built through the validating mkHash. The whole string is rejected when it carries no component or any component is malformed, so a partially-valid value never yields a partial digest set.

This is the one intended path from wire data to SRI hashes. Because each resulting Hash holds exactly one component, the admission floor, the worker's tamper gate, and the divergence fingerprint all resolve the same algorithm and digest body from it -- there is no joined string left for two consumers to read two different ways.

>>> fmap length (mkSriHashes "sha512-z4PhNX7vuL3xVChQ1m2AB9Yg5AULVxXcg/SpIdNs6c5H0NE8XYXysP+DGNKHfuwvY7kxvUdBeoGlODJ6+SfaPg== sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=")
Right 2
>>> mkSriHashes "  "
Left "malformed sri digest"

data HashAlg Source #

A hash algorithm an integrity digest is computed with.

The Ord instance is the integrity authority order, not constructor order: SRI < MD5 < SHA1 < SHA256 < SHA384 < Blake2b < SHA512. A bare SRI is a wrapper, not an algorithm; callers that care about its embedded algorithm should resolve it first.

Constructors

SHA1 
SHA256 
SHA384 
SHA512 
MD5 
Blake2b 
SRI

A single Subresource-Integrity component (npm dist.integrity), e.g. "sha512-…". Exactly one <alg>-<base64> component per Hash: a wire string that joins several with whitespace is split into one Hash per component by mkSriHashes, so every reader resolves the same algorithm and digest body from hashValue.

Instances

Instances details
Generic HashAlg Source # 
Instance details

Defined in Ecluse.Core.Package.Hash

Associated Types

type Rep HashAlg 
Instance details

Defined in Ecluse.Core.Package.Hash

type Rep HashAlg = D1 ('MetaData "HashAlg" "Ecluse.Core.Package.Hash" "ecluse-0.1.0-inplace-ecluse-core" 'False) ((C1 ('MetaCons "SHA1" 'PrefixI 'False) (U1 :: Type -> Type) :+: (C1 ('MetaCons "SHA256" 'PrefixI 'False) (U1 :: Type -> Type) :+: C1 ('MetaCons "SHA384" 'PrefixI 'False) (U1 :: Type -> Type))) :+: ((C1 ('MetaCons "SHA512" 'PrefixI 'False) (U1 :: Type -> Type) :+: C1 ('MetaCons "MD5" 'PrefixI 'False) (U1 :: Type -> Type)) :+: (C1 ('MetaCons "Blake2b" 'PrefixI 'False) (U1 :: Type -> Type) :+: C1 ('MetaCons "SRI" 'PrefixI 'False) (U1 :: Type -> Type))))

Methods

from :: HashAlg -> Rep HashAlg x #

to :: Rep HashAlg x -> HashAlg #

Show HashAlg Source # 
Instance details

Defined in Ecluse.Core.Package.Hash

Eq HashAlg Source # 
Instance details

Defined in Ecluse.Core.Package.Hash

Methods

(==) :: HashAlg -> HashAlg -> Bool #

(/=) :: HashAlg -> HashAlg -> Bool #

Ord HashAlg Source # 
Instance details

Defined in Ecluse.Core.Package.Hash

Universe HashAlg Source # 
Instance details

Defined in Ecluse.Core.Package.Hash

Methods

universe :: [HashAlg] Source #

type Rep HashAlg Source # 
Instance details

Defined in Ecluse.Core.Package.Hash

type Rep HashAlg = D1 ('MetaData "HashAlg" "Ecluse.Core.Package.Hash" "ecluse-0.1.0-inplace-ecluse-core" 'False) ((C1 ('MetaCons "SHA1" 'PrefixI 'False) (U1 :: Type -> Type) :+: (C1 ('MetaCons "SHA256" 'PrefixI 'False) (U1 :: Type -> Type) :+: C1 ('MetaCons "SHA384" 'PrefixI 'False) (U1 :: Type -> Type))) :+: ((C1 ('MetaCons "SHA512" 'PrefixI 'False) (U1 :: Type -> Type) :+: C1 ('MetaCons "MD5" 'PrefixI 'False) (U1 :: Type -> Type)) :+: (C1 ('MetaCons "Blake2b" 'PrefixI 'False) (U1 :: Type -> Type) :+: C1 ('MetaCons "SRI" 'PrefixI 'False) (U1 :: Type -> Type))))

Algorithm vocabulary

renderHashAlg :: HashAlg -> Text Source #

The lower-case wire name of an algorithm -- the canonical spelling parseHashAlg reads back. Total and injective, so it doubles as config rendering and error text.

>>> renderHashAlg SHA256
"sha256"

parseHashAlg :: Text -> Either Text HashAlg Source #

Parse an algorithm name, tolerating surrounding whitespace and case, and a single family-separating '-' (so "SHA-256" and "sha256" both parse). It accepts only the canonical names and their documented single-dash aliases: it does not strip arbitrary internal dashes, so a typo such as "s-h-a--2-5-6" is rejected rather than silently read as sha256. An unrecognised name is reported as such, distinct from a recognised-but-too-weak floor. The sri wrapper is not a config-selectable algorithm and is rejected.

>>> parseHashAlg "SHA-256"
Right SHA256
>>> parseHashAlg "frobnicate"
Left "unknown integrity algorithm: frobnicate"

sriPrefix :: Text -> Text Source #

The algorithm-name token of a Subresource-Integrity string -- the <alg> before the first '-' in <alg>-<base64>. A string with no '-' is all prefix.

>>> sriPrefix "sha512-Zm9vYmFy"
"sha512"

sriBody :: Text -> Text Source #

The base64 digest body of a Subresource-Integrity string -- the <base64> after the first '-' in <alg>-<base64>. A string with no '-' has an empty body.

>>> sriBody "sha512-Zm9vYmFy"
"Zm9vYmFy"

sriAlgorithm :: Text -> Maybe HashAlg Source #

The HashAlg a Subresource-Integrity string names, read from its <alg> prefix. The prefixes resolved are the Subresource-Integrity set sha256, sha384 and sha512 (every long digest the model represents and a registry serves); an unrecognised or malformed prefix yields Nothing, so the string asserts no algorithm and clears no floor (the fail-closed reading).

>>> sriAlgorithm "sha512-Zm9vYmFy"
Just SHA512
>>> sriAlgorithm "sha384-Zm9vYmFy"
Just SHA384

Digest computation

computeDigest :: HashAlg -> Maybe (LByteString -> ByteString) Source #

Compute the digest of bytes in a given algorithm, as the raw digest bytes, or Nothing for an algorithm Écluse will not verify against. The computable algorithms are exactly the collision-resistant ones: SHA1, SHA256, SHA384, SHA512, and Blake2b-512. MD5 is deliberately uncomputable here (a match on a broken hash cannot prove the bytes were not substituted, so the tamper gate never verifies against it), as is the bare SRI wrapper, which names no algorithm of its own (resolve it with sriAlgorithm first).

This is the sibling of hexDigestOk: both dispatch on the same per-algorithm crypto type, so they live together and a new HashAlg must be given an arm in each (the 'case' is total, and the package builds with -Wincomplete-patterns as an error). It is the one place that defines which algorithms the worker can verify; the integrity floor admits by strength (Ecluse.Core.Package.Integrity), and the invariant that every floor-clearing algorithm is computable here keeps the worker able to verify whatever the floor admits.

isComputable :: HashAlg -> Bool Source #

Whether the worker can compute (and so verify a digest in) the given algorithm: the predicate form of computeDigest, taken from the same single definition so the computable set cannot drift from what computeDigest actually computes.

>>> isComputable SHA256
True
>>> isComputable MD5
False

Dependencies

People

data Person Source #

A person associated with a package (author, maintainer, or publisher).

Constructors

Person 

Fields

Instances

Instances details
Show Person Source # 
Instance details

Defined in Ecluse.Core.Package

Eq Person Source # 
Instance details

Defined in Ecluse.Core.Package

Methods

(==) :: Person -> Person -> Bool #

(/=) :: Person -> Person -> Bool #

Ord Person Source # 
Instance details

Defined in Ecluse.Core.Package

Per-version details

data PackageDetails Source #

The ecosystem-agnostic snapshot of a single package version that the rules engine evaluates. A registry adapter projects its wire format into this; the rules engine never sees anything else, and never branches on the ecosystem.

Constructors

PackageDetails 

Fields

  • pkgName :: PackageName

    The package identity this snapshot belongs to.

  • pkgVersion :: Version

    The specific version this snapshot describes.

  • pkgPublishedAt :: Maybe UTCTime

    When this version was published, if known (absent from some cheap metadata views).

  • pkgInstallCode :: CodeExecSignal

    Whether installing the version executes code.

  • pkgTrust :: Trust

    The trust/provenance signal for the version.

  • pkgAvailability :: Availability

    Whether the version is offered, deprecated, or withdrawn.

  • pkgArtifacts :: NonEmpty Artifact

    The version's distribution files (one for npm; many for PyPI/RubyGems).

  • pkgLicenses :: [Text]

    Declared licenses (SPDX expressions/ids); may be several.

  • pkgPublisher :: Maybe Person

    Who published this version, if known (provenance).

    Dependencies and maintainers are deliberately not modelled (architect ruling, 2026-07-02). Dependencies are structurally redundant on the decision surface: a dependency only ever matters when it is itself fetched, and that fetch comes back through this same gate and receives its own verdict, so gating a parent's dependency list would duplicate the gate that already sits on every child request. Not modelling them means the wire layer does not even parse them (a heavy packument carries thousands of per-version dependency entries of pure parse cost on the hot path), and a malformed entry there can no longer drop the version -- it degrades, per the same ruling. The raw document still carries everything to the client untouched; the served surface is lossless regardless of what the decision surface models. If a dependency-reading rule ever genuinely lands, restore the Dependency/DepKind vocabulary from history and re-model then.

Instances

Instances details
Show PackageDetails Source # 
Instance details

Defined in Ecluse.Core.Package

Eq PackageDetails Source # 
Instance details

Defined in Ecluse.Core.Package

Packument-level view

data PackageInfo Source #

The packument-level view of a package: the whole-package metadata document (PackageDetails is the per-version snapshot embedded within it). A registry adapter projects a registry's packument (the npm full-metadata document) into this; the proxy core reasons over it without ever seeing the wire format.

Constructors

PackageInfo 

Fields

Instances

Instances details
Show PackageInfo Source # 
Instance details

Defined in Ecluse.Core.Package

Eq PackageInfo Source # 
Instance details

Defined in Ecluse.Core.Package

data InvalidEntry Source #

A single packument entry a registry projection dropped as malformed rather than failing the entire document, kept so the drop is observable rather than silent (an operator can see that an upstream served a malformed entry, and which). Each ecosystem's projection populates this from its own wire shape, so the drop-and-track contract is the same across npm, PyPI, and RubyGems.

Constructors

InvalidEntry 

Fields

  • invalidKind :: InvalidEntryKind

    Which kind of packument entry was dropped.

  • invalidKey :: Text

    The map key the dropped entry sat under: the raw version string for a version manifest or publish time, the tag name for a dist-tag.

  • invalidValue :: Value

    The raw offending value, preserved verbatim (Value is lossless), so an operator can see exactly what the upstream sent rather than only a reason string. A dropped publish time keeps its raw bad date here even though the version's pkgPublishedAt folds to Nothing; the gating value (absent) and the diagnostic (the raw bytes) are kept separate. Render it (truncating if large) at log time.

  • invalidReason :: Text

    Why the entry could not be projected (the decode error), for the operator log.

Instances

Instances details
Show InvalidEntry Source # 
Instance details

Defined in Ecluse.Core.Package

Eq InvalidEntry Source # 
Instance details

Defined in Ecluse.Core.Package

data InvalidEntryKind Source #

Which kind of registry-document entry a dropped InvalidEntry came from. A version manifest drop removes a serve candidate (fail-closed for that one version); a dist-tag or publish-time drop loses only that advisory datum while the version it referred to still resolves.

Constructors

InvalidVersionManifest

A versions entry whose manifest did not project (no dist/tarball, an unusable version).

InvalidDistTag

A dist-tags entry whose target was not a usable version string.

InvalidPublishTime

A time entry, keyed by a present version, that was not a decodable instant.