packages feed

git-annex-10.20261026: Command/VerifyRemote.hs

{- git-annex command
 -
 - Copyright 2026 Joey Hess <id@joeyh.name>
 -
 - Licensed under the GNU AGPL version 3 or higher.
 -}

{-# LANGUAGE OverloadedStrings #-}

module Command.VerifyRemote where

import Command
import qualified Logs.Remote
import qualified Logs.VerifiedRemote
import Logs.VerifiedRemote (VerifyingRemoteConfigId(..))
import qualified Remote
import qualified Types.Remote as Remote
import Types.RemoteConfig
import Types.ProposedAccepted
import Config
import Annex.SpecialRemote.Config

import qualified Data.Map as M
import qualified Data.Set as S

cmd :: Command
cmd = command "verifyremote" SectionSetup
	"verify if a special remote is safe to use"
	paramName
	(seek <$$> optParser)

data VerifyRemoteOptions = VerifyRemoteOptions
	{ remote :: DeferredParse Remote
	, safeunsafe :: Maybe SafeUnsafe
	}

data SafeUnsafe = Safe VerifyingRemoteConfigId | Unsafe

optParser :: CmdParamsDesc -> Parser VerifyRemoteOptions
optParser _ = VerifyRemoteOptions
	<$> (mkParseRemoteOption <$> remotearg)
	<*> optional safeUnsafeParser
  where
	remotearg = strArgument
		( metavar paramRemote
		<> help "remote to verify"
		<> completeRemotes
		)

safeUnsafeParser :: Parser SafeUnsafe
safeUnsafeParser = safe <|> unsafe
  where
	safe = option (Safe . VerifyingRemoteConfigId <$> maybeReader readish)
		( long "safe" <> metavar "ID"
		<> help "special remote configuration is safe"
		)
	unsafe = flag' Unsafe
		( long "unsafe"
		<> help "special remote configuration is unsafe"
		)

seek :: VerifyRemoteOptions -> CommandSeek
seek = commandAction . start

start :: VerifyRemoteOptions -> CommandStart
start o = do
	r <- getParsed (remote o)
	let si = SeekInput [Remote.name r]
	let ai = ActionItemUUID
		(Remote.uuid r)
		(UnquotedString (Remote.name r))
	case safeunsafe o of
		Just (Safe cid) -> starting "verifyremote" ai si $
			performVerifySafe cid r
		Just Unsafe -> starting "verifyremote" ai si $
			performVerifyUnsafe r
		Nothing -> startVerify r si

startVerify :: Remote -> SeekInput -> CommandStart
startVerify r si = do
	showCustom "verifyremote" si $ do
		remoteconfig <- getUnverifiedRemoteConfig (logUUID r)
		cid <- Logs.VerifiedRemote.storeVerifyingRemoteLog remoteconfig
		verifiedremoteconfig <- getVerifiedRemoteConfig (logUUID r)
		let msg = fromMaybe nochanges $
			remoteVerificationMessage' (Remote.name r) remoteconfig cid verifiedremoteconfig
		showRaw (encodeBS msg)
		return True
	stop
  where
	nochanges = "There have been no changes to the remote's configuration since it was last verified."

remoteVerificationMessage :: RemoteName -> UUID -> RemoteConfig -> Annex (Maybe String)
remoteVerificationMessage remotename u remoteconfig = 
	remoteVerificationMessage' remotename remoteconfig
		<$> Logs.VerifiedRemote.storeVerifyingRemoteLog remoteconfig
		<*> getVerifiedRemoteConfig u

-- Nothing when there are no changes to the RemoteConfig 
-- that need verification.
remoteVerificationMessage' :: RemoteName -> RemoteConfig -> VerifyingRemoteConfigId -> RemoteConfig -> Maybe String
remoteVerificationMessage' remotename remoteconfig cid verifiedremoteconfig
	| visiblefields verifiedremoteconfig == visibleremoteconfig = Nothing
	| otherwise = Just $
		if verifiedremoteconfig /= mempty
			then updatemessage
			else newmessage
  where
	newmessage = unlines
		[ "This is the configuration of the remote:"
		, "\t" ++ displayRemoteConfig visibleremoteconfig
		, ""
		, askexpected
		, "If so, run:  " ++ verifysafecommand
		, "If not, run: " ++ verifyunsafecommand
		]
	
	updatemessage = unlines
		[ "There is an updated configuration of the remote:"
		, "\t" ++ displayRemoteConfig visibleremoteconfig
		, ""
		, askexpected
		, "If so, run:  " ++ verifysafecommand
		, "If not, you do not need to do anything; the previously"
		, "verified configuration of the remote will continue to"
		, "be used."
		]
	
	askexpected = "Is this a location that you expected to be using?"

	verifysafecommand = "git-annex verifyremote "
		 ++ remotename ++ " --safe "
		 ++ show (fromVerifyingRemoteConfigId cid)

	verifyunsafecommand = "git-annex verifyremote "
		++ remotename ++ " --unsafe"
	
	visibleremoteconfig = visiblefields remoteconfig
	
	visiblefields = Logs.VerifiedRemote.omitUnverifiableFields
		. flip M.withoutKeys omitfields
	
	-- Fields that do not need to be verified.
	omitfields = S.fromList
		[ nameField
		, sameasNameField
		, sameasUUIDField
		]

performVerifySafe :: VerifyingRemoteConfigId -> Remote -> CommandPerform
performVerifySafe cid r = do
	remoteconfig <- getUnverifiedRemoteConfig (logUUID r)
	m <- Logs.VerifiedRemote.readVerifyingRemoteLog
	case M.lookup cid m of
		Just rc
			| rc == Logs.VerifiedRemote.omitUnverifiableFields remoteconfig -> do
				Logs.VerifiedRemote.configSet (logUUID r) remoteconfig
				setRemoteConfigVerification r RemoteConfigVerified
				Logs.VerifiedRemote.removeVerifyingLog cid
				next $ return True
		_ -> do
			warning "invalid ID"
			next $ return False

performVerifyUnsafe :: Remote -> CommandPerform
performVerifyUnsafe r = do
	m <- fromMaybe mempty <$> Logs.VerifiedRemote.readRemoteLog
	let hasverifiedconfig = M.member (logUUID r) m
	warning $ UnquotedString $ unlines $ 
		warningVerifyUnsafe
			(Remote.name r)
			hasverifiedconfig
			(remoteAnnexVerified (Remote.gitconfig r))
	next $ return False

warningVerifyUnsafe :: RemoteName -> Bool -> RemoteConfigVerification -> [String]
warningVerifyUnsafe name False RemoteConfigUnverifiedAutoEnabled =
	[ "So the autoenabled remote " ++ name ++ " is in a location"
	, "you did not expect to be using. You may be under attack."
	, ""
	, "No annexed files have been sent to that remote or any other"
	, "remote since it was enabled, and git pushes to all remotes"
	, "have also been prevented since that time."
	, ""
	, "But, annexed files may have been copied from that remote into"
	, "the local repository. There is a risk that those files could"
	, "be sent on to other remotes, or that information in the git-annex"
	, "branch could be pushed later, leaking information about the"
	, "content of " ++ name ++ "."
	, ""
	, "Also, if the remote supports git pulls, branches may have been"
	, "pulled from it into this repository, which may leak data"
	, "to it when pushed elsewhere."
	, ""
	, "Your options for dealing with this include:"
	, "* Identify who caused " ++ name ++ " to be configured this way,"
	, "  and remove any remotes under their control to avoid leaking data"
	, "  to them."
	, "* Delete this local git repository and all its contents."
	, "  A good choice if you know you will not lose any work or data."
	, ""
	, "As long as the remote " ++ name ++ " exists, annexed files"
	, "will continue to be blocked from being sent to any remote,"
	, "and git pushes will also be blocked. Once you have dealt"
	, "with this attack, you can unblock by running:"
	, "\tgit remote remove " ++ name
	]
warningVerifyUnsafe name False RemoteConfigUnverifiedManualEnabled =
	[ "So the remote " ++ name ++ " is in a location you did not expect"
	, "to be using. You may be under attack."
	, ""
	, "No annexed files have been sent to or retrieved from the"
	, "remote since it was enabled."
	, ""
	, "But, if the remote supports git fetch, branches may have been"
	, "pulled from it into this repository. If you determine that"
	, "git has fetched information from the remote, you may need to"
	, "clean up those branches to avoid leaking information from the"
	, "remote. Bear in mind that the git-annex branch could be amoung"
	, "them."
	, ""
	, "Git pushes to all remotes have been blocked since remote"
	, name ++ " was enabled, and will stay blocked as long as it exists."
	, "Once you have dealt with this attack, you can unblock by running:"
	, "\tgit remote remove " ++ name
	]
warningVerifyUnsafe name _ _ =
	[ "Unfortunately, information may have leaked from " ++ name ++ "."
	, ""
	, "Either it has already been verified as safe, or it was"
	, "enabled by a previous version of git-annex that did not verify"
	, "the location of special remotes. So nothing prevented"
	, "information from it being sent to other remotes."
	]

getUnverifiedRemoteConfig :: UUID -> Annex RemoteConfig
getUnverifiedRemoteConfig u = do
	m <- Logs.Remote.readRemoteLog
	return $ fromMaybe mempty (M.lookup u m)

getVerifiedRemoteConfig :: UUID -> Annex RemoteConfig
getVerifiedRemoteConfig u = do
	m <- fromMaybe mempty <$> Logs.VerifiedRemote.readRemoteLog
	return $ fromMaybe mempty (M.lookup u m)

logUUID :: Remote -> UUID
logUUID r = fromMaybe (Remote.uuid r) $
	remoteAnnexConfigUUID (Remote.gitconfig r)

displayRemoteConfig :: RemoteConfig -> String
displayRemoteConfig = intercalate " " . map display . sortBy order . M.toList
  where
	display (f, v) =
		fromProposedAccepted f ++ "=" ++ fromProposedAccepted v
	order (f1, v1) (f2, v2) = 
		conventionalConfigOrder f1 f2 <> compare v1 v2