seihou-core-0.16.0.0: src/Seihou/Core/ArtifactIdentity.hs
-- | When two recorded artifact origins name the same artifact.
--
-- The manifest identifies an artifact by its origin plus its name (see
-- docs\/adr\/0002-artifact-identity-is-origin-url-plus-name.md), and several
-- places have to ask whether two such identities are the same one: the
-- blueprint-migration receipt ledger in 'Seihou.Manifest.Types' when it
-- upserts a receipt, the pending-edge filter in
-- 'Seihou.CLI.BlueprintMigration' when it decides what still has to run, and
-- the pre-generation guard in 'Seihou.CLI.ManifestGuard' when it compares
-- what the manifest records against what is installed here. They must agree,
-- or a receipt could be written as a new entry while being read as a
-- duplicate, so the comparison lives in one place.
--
-- This module answers a plain yes-or-no question. The richer three-way
-- judgement that distinguishes "different artifact" from "cannot be proved
-- either way" belongs to 'Seihou.CLI.ManifestGuard.judgeArtifact' and is not
-- appropriate here: a receipt either records this exact identity or it does
-- not, with no unverifiable middle ground.
module Seihou.Core.ArtifactIdentity
( sameArtifactIdentity,
sameRepositoryOrigin,
RepositoryOriginKey,
repositoryOriginKey,
normalizeOriginUrl,
normalizeProjectPath,
isMachineLocalOriginUrl,
)
where
import Control.Monad (guard)
import Data.Char (isAsciiLower, isAsciiUpper, isControl, isDigit, isHexDigit, isSpace)
import Data.Maybe (fromMaybe, isJust, isNothing)
import Data.Text qualified as T
import Seihou.Core.Types (ArtifactOrigin (..))
import Seihou.Prelude
-- | Whether two recorded origins name the same artifact.
--
-- Two origins of different kinds are never the same artifact. Within a kind
-- the comparison is structural, after normalising away spellings that differ
-- without meaning anything: remote URLs are compared with
-- 'sameRepositoryOrigin', and project-relative paths ignore a @.\/@ prefix
-- and trailing slashes.
--
-- A 'LocalOrigin' carries no provenance at all, so two of them compare equal
-- exactly when they carry the same name. That is deliberately weak — it is
-- also the strongest statement available about an artifact seihou can only
-- identify by name — and it is what makes two receipts written before origins
-- were recorded still match each other.
sameArtifactIdentity :: ArtifactOrigin -> ArtifactOrigin -> Bool
sameArtifactIdentity left right = case (left, right) of
(RemoteOrigin leftUrl leftName _, RemoteOrigin rightUrl rightName _) ->
sameRepositoryOrigin leftUrl rightUrl
&& leftName == rightName
(ProjectOrigin leftPath, ProjectOrigin rightPath) ->
normalizeProjectPath leftPath == normalizeProjectPath rightPath
(LocalOrigin leftName, LocalOrigin rightName) -> leftName == rightName
_ -> False
-- | Reduce a git URL to a form two spellings of the same repository share,
-- lexically.
--
-- @https:\/\/host\/repo@, @https:\/\/host\/repo.git@ and
-- @https:\/\/host\/repo\/@ all reduce to one text. This is a cosmetic
-- helper for keys that must stay specific to one connection method — a
-- session clone cache, the grouping of machine-local paths during repair —
-- and it never equates two transports. Identity decisions use
-- 'sameRepositoryOrigin' or 'repositoryOriginKey' instead. Never use the
-- result as a clone URL.
normalizeOriginUrl :: Text -> Text
normalizeOriginUrl = cosmeticPath . T.strip
-- | Trailing slashes and at most one terminal @.git@ of a non-empty final
-- component, so @repo\/.git@ keeps its separate @.git@ component.
cosmeticPath :: Text -> Text
cosmeticPath path =
let trimmed = dropTrailingSlashes path
in case T.stripSuffix ".git" trimmed of
Just base | not (T.null base), T.last base /= '/' -> dropTrailingSlashes base
_ -> trimmed
where
dropTrailingSlashes = T.dropWhileEnd (== '/')
-- | Whether two recorded git URLs name the same repository.
--
-- This is the one repository-identity comparison seihou makes: the manifest
-- guard, install collision checks, migration receipts, repair evidence and
-- update staging all ask it. The recorded text is never rewritten; this only
-- answers the question. See 'repositoryOriginKey' for the rules.
sameRepositoryOrigin :: Text -> Text -> Bool
sameRepositoryOrigin left right = repositoryOriginKey left == repositoryOriginKey right
-- | The repository a recorded git URL identifies, as an opaque comparable
-- key. Two URLs have equal keys exactly when 'sameRepositoryOrigin' says
-- they name the same repository, so the key can index a map.
--
-- The constructors are private. Their distinct tags keep classes apart: a
-- recognised public GitHub repository, a generic scheme URL, a generic
-- scp-style address, and opaque text (local paths, @file:@, unsupported
-- schemes, and anything malformed) can never compare equal to one another.
data RepositoryOriginKey
= -- | Owner and repository on public GitHub.
GitHubRepository !Text !Text
| -- | Scheme, user information, host, non-default port, path, query and
-- fragment of a generic network URL. Absent and empty query or
-- fragment differ.
SchemeEndpoint !Text !(Maybe Text) !Text !(Maybe Int) !Text !(Maybe Text) !(Maybe Text)
| -- | User, host and path of a generic scp-style address. A relative path
-- may be relative to the user's home and is kept as written.
ScpEndpoint !(Maybe Text) !Text !Text
| -- | Text no supported form recognised, after cosmetic normalisation.
OpaqueOrigin !Text
deriving stock (Eq, Ord, Show, Generic)
-- | Parse a recorded git URL into its 'RepositoryOriginKey'. Total, pure and
-- offline: no DNS, filesystem, git configuration or network.
--
-- The text is parsed before anything is normalised, so cleanup can never
-- manufacture a match. Public GitHub has one documented equivalence class:
-- @https:\/\/github.com\/OWNER\/REPO@ (port 443 allowed, user information
-- ignored), @ssh:\/\/git\@github.com\/OWNER\/REPO@ (port 22 allowed),
-- @git\@github.com:OWNER\/REPO@, and @ssh:\/\/git\@ssh.github.com:443\/OWNER\/REPO@,
-- each with an optional terminal @.git@ and trailing slashes. The path must be
-- exactly two ordinary components with no query, fragment, escape or empty
-- segment. Everything else compares conservatively: generic URLs keep their
-- scheme, user, non-default port, path, query and fragment; scp-style
-- addresses keep their user and relative path and never equal a scheme URL.
repositoryOriginKey :: Text -> RepositoryOriginKey
repositoryOriginKey raw
| T.any (\c -> isSpace c || isControl c) url = opaque
| isMachineLocalOriginUrl url = opaque
| (scheme, rest) <- T.breakOn "://" url,
not (T.null rest) =
fromMaybe opaque (parseSchemeUrl (T.toLower scheme) (T.drop 3 rest))
| otherwise = fromMaybe opaque (parseScp url)
where
url = T.strip raw
opaque = OpaqueOrigin (normalizeOriginUrl url)
parseSchemeUrl :: Text -> Text -> Maybe RepositoryOriginKey
parseSchemeUrl scheme rest = do
defaultPort <- lookup scheme [("https", 443), ("http", 80), ("ssh", 22), ("git", 9418)]
let (beforeFragment, fragment) = splitDelimiter '#' rest
(beforeQuery, query) = splitDelimiter '?' beforeFragment
(authority, path) = T.break (== '/') beforeQuery
(user, hostPort) <- case T.splitOn "@" authority of
[hostPort] -> Just (Nothing, hostPort)
[user, hostPort] -> Just (Just user, hostPort)
_ -> Nothing
(host, port) <- parseHostPort hostPort
let explicitPort = if port == Just defaultPort then Nothing else port
generic = do
let cleaned = cosmeticPath path
if T.null cleaned
then Nothing
else Just (SchemeEndpoint scheme user host explicitPort cleaned query fragment)
github = do
guard (isNothing query && isNothing fragment)
guard $ case (scheme, user, host, port) of
("https", _, "github.com", Nothing) -> True
("https", _, "github.com", Just 443) -> True
("ssh", Just "git", "github.com", Nothing) -> True
("ssh", Just "git", "github.com", Just 22) -> True
("ssh", Just "git", "ssh.github.com", Just 443) -> True
_ -> False
githubPath =<< T.stripPrefix "/" path
maybe generic Just github
-- | Split at the first delimiter, keeping an empty value distinct from an
-- absent delimiter.
splitDelimiter :: Char -> Text -> (Text, Maybe Text)
splitDelimiter delimiter text = case T.break (== delimiter) text of
(before, after)
| T.null after -> (before, Nothing)
| otherwise -> (before, Just (T.drop 1 after))
-- | A lowercased DNS host or bracketed IPv6 literal, and an optional port in
-- 1..65535. An empty or out-of-range port is malformed.
parseHostPort :: Text -> Maybe (Text, Maybe Int)
parseHostPort text = do
(host, portPart) <-
if "[" `T.isPrefixOf` text
then case T.breakOn "]" text of
(_, close) | T.null close -> Nothing
(open, close) -> do
let literal = T.drop 1 open
guard (not (T.null literal) && T.all (\c -> isHexDigit c || c == ':' || c == '.') literal)
Just (T.toLower (open <> "]"), T.drop 1 close)
else do
let (name, portPart) = T.break (== ':') text
guard (validDnsHost name)
Just (T.toLower name, portPart)
port <- case T.uncons portPart of
Nothing -> Just Nothing
Just (':', digits) -> Just <$> parsePort digits
Just _ -> Nothing
Just (host, port)
parsePort :: Text -> Maybe Int
parsePort digits = do
guard (not (T.null digits) && T.length digits <= 5 && T.all isDigit digits)
let value = T.foldl' (\acc c -> acc * 10 + fromEnum c - fromEnum '0') 0 digits
guard (value >= 1 && value <= 65535)
Just value
validDnsHost :: Text -> Bool
validDnsHost name =
not (T.null name)
&& T.all (\c -> isAsciiLower c || isAsciiUpper c || isDigit c || c == '-' || c == '.') name
&& not ("." `T.isPrefixOf` name)
&& not (".." `T.isInfixOf` name)
-- | Git's scp-like syntax, @[user\@]host:path@, recognised only when no
-- slash precedes the first colon. Local and Windows paths were excluded
-- before this is tried.
parseScp :: Text -> Maybe RepositoryOriginKey
parseScp text = do
let (beforeColon, afterColon) = T.break (== ':') text
guard (not (T.null afterColon) && not ("/" `T.isInfixOf` beforeColon))
(user, rest) <- case T.break (== '@') text of
(candidate, at)
| not (T.null at),
T.all (\c -> c /= ':' && c /= '/' && c /= '[') candidate ->
if T.null candidate then Nothing else Just (Just candidate, T.drop 1 at)
_ -> Just (Nothing, text)
(hostPart, path) <- splitHost rest
(host, port) <- parseHostPort hostPart
let cleaned = cosmeticPath path
guard (isNothing port && not (T.null cleaned))
-- A one-letter host with no user is a drive designator, not a server.
guard (isJust user || T.length host > 1)
let github = do
guard (user == Just "git" && host == "github.com")
githubPath path
maybe (Just (ScpEndpoint user host cleaned)) Just github
where
splitHost rest
| "[" `T.isPrefixOf` rest = case T.breakOn "]:" rest of
(_, close) | T.null close -> Nothing
(open, close) -> Just (open <> "]", T.drop 2 close)
| otherwise = case T.break (== ':') rest of
(host, colonPath) | not (T.null colonPath) -> Just (host, T.drop 1 colonPath)
_ -> Nothing
-- | @OWNER\/REPO@ with optional trailing slashes and at most one terminal
-- @.git@, both components ordinary ASCII names. Case is preserved.
githubPath :: Text -> Maybe RepositoryOriginKey
githubPath path = case T.splitOn "/" (T.dropWhileEnd (== '/') path) of
[owner, repoWithSuffix] -> do
let repo = fromMaybe repoWithSuffix (T.stripSuffix ".git" repoWithSuffix)
guard (ordinaryComponent owner && ordinaryComponent repo)
Just (GitHubRepository owner repo)
_ -> Nothing
where
ordinaryComponent component =
not (T.null component)
&& component /= "."
&& component /= ".."
&& T.all (\c -> isAsciiLower c || isAsciiUpper c || isDigit c || c == '-' || c == '_' || c == '.') component
-- | Reduce a project-relative path to a comparable form. The manifest stores
-- these with forward slashes; @.\/@ prefixes and trailing slashes are noise.
normalizeProjectPath :: FilePath -> FilePath
normalizeProjectPath =
dropWhileEnd' (== '/') . dropDotPrefix . dropWhileEnd' (== '/')
where
dropDotPrefix path = fromMaybe path (stripPrefix' "./" path)
stripPrefix' prefix path =
if take (length prefix) path == prefix
then Just (drop (length prefix) path)
else Nothing
dropWhileEnd' p = reverse . dropWhile p . reverse
-- | Whether a recorded origin URL is a path on the machine that recorded it
-- rather than a location every developer can reach.
--
-- @seihou install@ accepts a local checkout, and before plan 98 it stored
-- that path verbatim as the installed copy's @sourceUrl@, which then reached
-- the manifest as a 'RemoteOrigin'. A path means nothing on another machine
-- (docs\/adr\/0001-manifest-is-a-checked-in-machine-independent-artifact.md)
-- and turns into a false origin mismatch on this one as soon as the artifact
-- is reinstalled from its real remote.
--
-- True for text that, once stripped, starts with @\/@, @.\/@, @..\/@, @~@
-- or @file:@, is exactly @.@ or @..@, or is a Windows drive path
-- (@C:\\@ or @C:\/@). False for URLs with a network scheme
-- (@https:\/\/@, @ssh:\/\/@, @git:\/\/@) and for scp-style
-- @user\@host:path@.
isMachineLocalOriginUrl :: Text -> Bool
isMachineLocalOriginUrl raw =
any (`T.isPrefixOf` url) ["/", "./", "../", "~", "file:"]
|| url == "."
|| url == ".."
|| isDrivePath url
where
url = T.strip raw
isDrivePath text = case T.unpack (T.take 3 text) of
[letter, ':', sep] -> isDriveLetter letter && (sep == '\\' || sep == '/')
_ -> False
isDriveLetter c = isAsciiLower c || isAsciiUpper c