diff --git a/Annex/Balanced.hs b/Annex/Balanced.hs
--- a/Annex/Balanced.hs
+++ b/Annex/Balanced.hs
@@ -11,13 +11,14 @@
 
 import Key
 import Types.UUID
-import Utility.HMAC
+import Utility.Hash.HMAC
+import Utility.Hash.Types
 
 import Data.Maybe
 import qualified Data.List as L
 import Data.Bits (shiftL)
 import qualified Data.Set as S
-import qualified "memory" Data.ByteArray as BA
+import qualified Data.ByteString as B
 
 -- The Int is how many UUIDs to pick.
 type BalancedPicker = S.Set UUID -> Key -> Int -> [UUID]
@@ -34,9 +35,9 @@
   where
 	combineduuids = mconcat (map fromUUID (S.toAscList s))
 
-	tointeger :: Digest a -> Integer
+	tointeger :: HashDigest -> Integer
 	tointeger = L.foldl' (\i b -> (i `shiftL` 8) + fromIntegral b) 0 
-		. BA.unpack
+		. B.unpack . hashDigestByteString
 
 {- The selection for a given key never changes. -}
 prop_balanced_stable :: Bool
diff --git a/Annex/DirHashes.hs b/Annex/DirHashes.hs
--- a/Annex/DirHashes.hs
+++ b/Annex/DirHashes.hs
@@ -22,7 +22,6 @@
 import Data.Default
 import Data.Bits
 import qualified Data.List.NonEmpty as NE
-import qualified "memory" Data.ByteArray as BA
 import qualified Data.ByteString as S
 
 import Common
@@ -80,8 +79,9 @@
 hashDirMixed :: HashLevels -> Hasher
 hashDirMixed n k = hashDirs n 2 $ S.pack $ take 4 $
 	concatMap display_32bits_as_dir $
-		encodeWord32 $ map fromIntegral $ BA.unpack $
-			md5s $ serializeKey' $ nonChunkKey k
+		encodeWord32 $ map fromIntegral $ 
+			S.unpack $ hashDigestByteString $
+				md5s $ serializeKey' $ nonChunkKey k
   where
 	encodeWord32 (b1:b2:b3:b4:rest) =
 		(shiftL b4 24 .|. shiftL b3 16 .|. shiftL b2 8 .|. b1)
diff --git a/Annex/Import.hs b/Annex/Import.hs
--- a/Annex/Import.hs
+++ b/Annex/Import.hs
@@ -55,7 +55,7 @@
 import Messages.Progress
 import Utility.DataUnits
 import Utility.Metered
-import Utility.Hash (sha1s)
+import Utility.Hash (sha1s, hashByteString, digestToHash)
 import Logs.Import
 import Logs.Export
 import Logs.Location
@@ -72,7 +72,6 @@
 import Control.Concurrent.STM
 import qualified Data.Map.Strict as M
 import qualified Data.Set as S
-import qualified "memory" Data.ByteArray.Encoding as BA
 #ifdef mingw32_HOST_OS
 import qualified System.FilePath.Posix as Posix
 #endif
@@ -410,7 +409,7 @@
 		-- ok, hopefully. This checksum never needs to be verified
 		-- by git, which is why this does not bother to prefix the
 		-- cid with its length, like git would.
-		sha1 = Ref $ BA.convertToBase BA.Base16 $ sha1s cid
+		sha1 = Ref $ hashByteString $ digestToHash $ sha1s cid
 
 buildImportTreesGeneric
 	:: (Maybe TopFilePath -> [(ImportLocation, v)] -> Annex Tree)
@@ -787,10 +786,10 @@
 			job <- liftIO $ newEmptyTMVarIO
 			let ai = ActionItemOther (Just (QuotedPath (fromImportLocation loc)))
 			let si = SeekInput []
-			let importaction = starting ("import " ++ Remote.name remote) ai si $ do
+			let importaction = do
 				when oldversion $
 					showNote "old version"
-				tryNonAsync (importordownload cidmap i largematcher) >>= \case
+				res <- tryNonAsync (importordownload cidmap i largematcher) >>= \case
 					Left e -> next $ do
 						warning (UnquotedString (show e))
 						liftIO $ atomically $
@@ -800,10 +799,12 @@
 						liftIO $ atomically $
 							putTMVar job r
 						return (isJust r)
-			commandAction $ bracket_
-				(waitstart importing cid)
-				(signaldone importing cid)
-				importaction
+				return res
+			commandAction $ starting ("import " ++ Remote.name remote) ai si $
+				bracket_
+					(waitstart importing cid)
+					(signaldone importing cid)
+					importaction
 			return (Right job)
 	
 	thirdpartypopulatedimport db (loc, (cid, sz)) = 
@@ -853,12 +854,12 @@
 				islargefile <- checkMatcher' matcher mi NoLiveUpdate mempty
 				metered Nothing sz bwlimit $ const $ if islargefile
 					then doimportlarge importkey cidmap loc cid sz f
-					else doimportsmall cidmap loc cid sz
+					else doimportsmall cidmap loc cid sz f
 	
 	doimportlarge importkey cidmap loc cid sz f p =
 		tryNonAsync importer >>= \case
-			Right (Just (k, True)) -> return $ Just (loc, Right k)
-			Right _ -> return Nothing
+			Right (Just v) -> return $ Just v
+			Right Nothing -> return Nothing
 			Left e -> do
 				warning (UnquotedString (show e))
 				return Nothing
@@ -877,55 +878,52 @@
 						logChange NoLiveUpdate k (Remote.uuid remote) InfoPresent
 						if importcontent
 							then getcontent k
-							else return (Just (k, True))
+							else return (Just (loc, Right k))
 					Just msg -> giveup (msg ++ " to import")
 
-		getcontent :: Key -> Annex (Maybe (Key, Bool))
 		getcontent k = do
-			let af = AssociatedFile (Just f)
 			let downloader p' tmpfile = do
 				_ <- Remote.retrieveImport
 					(Remote.importActions remote)
 					loc [cid] tmpfile
 					(Left k)
 					(combineMeterUpdate p' p)
-				ok <- moveAnnex k tmpfile
-				when ok $
-					logStatus NoLiveUpdate k InfoPresent
-				return (Just (k, ok))
-			checkDiskSpaceToGet k Nothing Nothing $
-				notifyTransfer Download af $
-					download' (Remote.uuid remote) k af Nothing stdRetry $ \p' ->
-						withTmp k $ downloader p'
-			
+				ifM (moveAnnex k tmpfile)
+					( do
+						logStatus NoLiveUpdate k InfoPresent
+						return (Just (loc, Right k))
+					, return Nothing
+					)
+			downloadimport k f $ \p' ->
+				withTmp k $ downloader p'
+	
 	-- The file is small, so is added to git, so while importing
 	-- without content does not retrieve annexed files, it does
 	-- need to retrieve this file.
-	doimportsmall cidmap loc cid sz p = do
-		let downloader tmpfile = do
+	doimportsmall cidmap loc cid sz f p = do
+		let downloader p' tmpfile = do
 			(k, _) <- Remote.retrieveImport
 				(Remote.importActions remote)
 				loc [cid] tmpfile
 				(Right (mkkey tmpfile))
-				p
+				(combineMeterUpdate p' p)
 			case keyGitSha k of
 				Just sha -> do
 					recordcidkey cidmap cid k
 					return sha
 				Nothing -> error "internal"
-		checkDiskSpaceToGet tmpkey Nothing Nothing $
+		downloadimport tmpkey f $ \p' ->
 			withTmp tmpkey $ \tmpfile ->
-				tryNonAsync (downloader tmpfile) >>= \case
+				tryNonAsync (downloader p' tmpfile) >>= \case
 					Right sha -> return $ Just (loc, Left sha)
 					Left e -> do
 						warning (UnquotedString (show e))
 						return Nothing
 	  where
-		tmpkey = importKey cid sz
+		tmpkey = tmpImportKey cid sz
 		mkkey tmpfile = gitShaKey <$> hashFile tmpfile
 	
 	dodownload cidmap (loc, (cid, sz)) f matcher = do
-		let af = AssociatedFile (Just f)
 		let downloader tmpfile p = do
 			(k, _) <- Remote.retrieveImport
 				(Remote.importActions remote)
@@ -949,14 +947,12 @@
 			Left e -> do
 				warning (UnquotedString (show e))
 				return Nothing
-		checkDiskSpaceToGet tmpkey Nothing Nothing $
-			notifyTransfer Download af $
-				download' (Remote.uuid remote) tmpkey af Nothing stdRetry $ \p ->
-					withTmp tmpkey $ \tmpfile ->
-						metered (Just p) tmpkey bwlimit $
-							const (rundownload tmpfile)
+		downloadimport tmpkey f $ \p ->
+			withTmp tmpkey $ \tmpfile ->
+				metered (Just p) tmpkey bwlimit $
+					const (rundownload tmpfile)
 	  where
-		tmpkey = importKey cid sz
+		tmpkey = tmpImportKey cid sz
 	
 		mkkey tmpfile = do
 			let mi = MatchingFile FileInfo
@@ -975,7 +971,7 @@
 						}
 					fst <$> genKey ks nullMeterUpdate backend
 				else gitShaKey <$> hashFile tmpfile
-				
+	
 	bwlimit = remoteAnnexBwLimitDownload (Remote.gitconfig remote)
 			<|> remoteAnnexBwLimit (Remote.gitconfig remote)
 
@@ -1021,11 +1017,19 @@
 		CIDLog.recordContentIdentifier rs cid k
 
 	rs = Remote.remoteStateHandle remote
+	
+	downloadimport :: Key -> OsPath -> (MeterUpdate -> Annex (Maybe (ImportLocation, (Either Sha Key)))) -> Annex (Maybe (ImportLocation, (Either Sha Key)))
+	downloadimport k f a =
+		checkDiskSpaceToGet k Nothing Nothing $
+			notifyTransfer Download af $
+				download' (Remote.uuid remote) k af Nothing stdRetry a
+	  where
+		af = AssociatedFile (Just f)	
 
 {- Temporary key used for import of a ContentIdentifier while downloading
  - content, before generating its real key. -}
-importKey :: ContentIdentifier -> Integer -> Key
-importKey (ContentIdentifier cid) size = mkKey $ \k -> k
+tmpImportKey :: ContentIdentifier -> Integer -> Key
+tmpImportKey (ContentIdentifier cid) size = mkKey $ \k -> k
 	{ keyName = genKeyName (decodeBS cid)
 	, keyVariety = OtherKey "CID"
 	, keySize = Just size
diff --git a/BuildFlags.hs b/BuildFlags.hs
--- a/BuildFlags.hs
+++ b/BuildFlags.hs
@@ -83,6 +83,11 @@
 #ifdef WITH_NOLLMDEPENDENCIES
 	, "NoLLMDependencies"
 #endif
+#ifdef WITH_TASTYTAP
+	, "TastyTap"
+#else
+#warning Building without tasty-tap support.
+#endif
 	]
 
 -- Not a complete list, let alone a listing transitive deps, but only
diff --git a/CHANGELOG b/CHANGELOG
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -1,3 +1,40 @@
+git-annex (10.20261005) upstream; urgency=medium
+
+  * Fix mask special remote to not hang after the first file.
+  * Fix concurrent import of identical files to not fail with
+    "transfer already in progress".
+  * Also fixes possible data corruption when importing identical small files.
+  * Use git-credential in more situations when accessing urls that need a
+    password or other authentication, including the web, bittorrent and
+    httpalso special remotes, and git-annex addurl and importfeed.
+  * external: Fix DOWNLOAD-URL to support git-credential, which it
+    was already documented to do.
+  * external: Support git-credential in TRANSFER-RETRIEVE-URL,
+    CHECKPRESENT-URL, and RETRIEVEIMPORT-URL
+  * webdav: Fix a hang when credentials are not available.
+    (Reversion introduced in 10.20260901)
+  * export: Display a warning when the user is either in the middle
+    of an export conflict, or has been unsafely writing directly to a
+    remote that is configured with exporttree=yes but without
+    importtree=yes.
+  * importfeed: Avoid displaying empty feed titles.
+  * Allow retrieval from some importtree=yes special remotes when there
+    is no recorded content identifier.
+  * Automate updating remote.name.annexUrl for annex+https servers,
+    by re-probing the http remote's annex.url config when unable to connect
+    to an annex+https server.
+  * test, testremote: Support --tap output when built with TastyTap build
+    flag.
+  * testremote: Add --ensure-readonly mode.
+  * Support bup 0.34, while also still working with previous versions.
+  * When built with Botan, also use it for HMAC.
+  * NoLLMDependencies: Update for crypton.
+  * NoLLMDependencies: Update for tls.
+  * Support building with QuickCheck 2.17.
+  * Support building with crypton 1.1.0.
+
+ -- Joey Hess <id@joeyh.name>  Mon, 05 Oct 2026 10:44:55 -0400
+
 git-annex (10.20260901) upstream; urgency=medium
 
   * Behavior change: drop --auto --from a remote does not any longer 
diff --git a/Command/AddUrl.hs b/Command/AddUrl.hs
--- a/Command/AddUrl.hs
+++ b/Command/AddUrl.hs
@@ -251,7 +251,7 @@
 	go url = startingAddUrl si urlstring o $
 		if relaxedOption (downloadOptions o)
 			then go' url Url.assumeUrlExists
-			else Url.withUrlOptions Nothing (Url.getUrlInfo urlstring) >>= \case
+			else Url.withUrlOptionsPromptingCreds Nothing (Url.getUrlInfo urlstring) >>= \case
 				Right urlinfo -> go' url urlinfo
 				Left err -> do
 					warning (UnquotedString err)
@@ -352,7 +352,7 @@
 	go =<< downloadWith' downloader urlkey webUUID url file
   where
 	urlkey = addSizeUrlKey urlinfo $ Backend.URL.fromUrl url Nothing True
-	downloader f p = Url.withUrlOptions Nothing $
+	downloader f p = Url.withUrlOptionsPromptingCreds Nothing $
 		downloadUrl False urlkey p Nothing [url] f
 	go Nothing = return Nothing
 	go (Just (tmp, backend)) = ifM (useYoutubeDl o <&&> liftIO (isHtmlFile tmp))
diff --git a/Command/Export.hs b/Command/Export.hs
--- a/Command/Export.hs
+++ b/Command/Export.hs
@@ -283,7 +283,10 @@
 	stopUnless (notrecordedpresent ek) $
 		starting ("export " ++ name r) ai si $
 			ifM (either (const False) id <$> tryNonAsync (checkPresentExport (exportActions r) ek loc))
-				( next $ cleanupExport r db ek loc False
+				( do
+					unlessM (isImportSupported r) $
+						warning presentwarning 
+					next $ cleanupExport r db ek loc False
 				, do
 					liftIO $ modifyMVar_ cvar (pure . const (FileUploaded True))
 					performExport r srcrs db ek af (Git.LsTree.sha ti) loc allfilledvar
@@ -308,6 +311,16 @@
 				then return False
 				else notElem (uuid r) <$> loggedLocations ek
 			)
+	
+	presentwarning = UnquotedString $ unwords
+		[ "A file by this name is already present in the remote."
+		, "This is typically due to an export conflict, which will"
+		, "be resolved by this command once the git-annex branch"
+		, "is in sync across all writers. (Or it may be due to"
+		, "something other than git-annex writing to the remote."
+		, "Since the remote is not configured with importtree=yes,"
+		, "such modifications will not be preserved.)"
+		]
 
 performExport :: Remote -> [Remote] -> ExportHandle -> Key -> AssociatedFile -> Sha -> ExportLocation -> MVar AllFilled -> CommandPerform
 performExport r srcrs db ek af contentsha loc allfilledvar = do
diff --git a/Command/ImportFeed.hs b/Command/ImportFeed.hs
--- a/Command/ImportFeed.hs
+++ b/Command/ImportFeed.hs
@@ -172,12 +172,13 @@
 	parse tmpf = liftIO (parseFeedFromFile' tmpf) >>= \case
 		Nothing -> debugfeedcontent tmpf "parsing the feed failed"
 		Just f -> do
-			let feedtitle = '"' : decodeBS (fromFeedText $ getFeedTitle f) ++ "\""
+			let qq s = '"' : s ++ "\""
+			let feedtitle = decodeBS (fromFeedText $ getFeedTitle f)
 			unless (null feedtitle) $
-				showNote (UnquotedString feedtitle)
+				showNote (UnquotedString (qq feedtitle))
 			let feeddesc = if null feedtitle
 				then url
-				else feedtitle
+				else qq feedtitle
 			case findDownloads url f feeddesc of
 				[] -> debugfeedcontent tmpf "bad feed content; no enclosures to download"
 				l -> do
@@ -274,7 +275,7 @@
 downloadFeed :: URLString -> OsPath -> Annex Bool
 downloadFeed url f
 	| Url.parseURIRelaxed url == Nothing = giveup "invalid feed url"
-	| otherwise = Url.withUrlOptions Nothing $
+	| otherwise = Url.withUrlOptionsPromptingCreds Nothing $
 		Url.download nullMeterUpdate Nothing url f
 
 startDownload :: AddUnlockedMatcher -> ImportFeedOptions -> Cache -> TMVar Bool -> ToDownload -> CommandStart
@@ -373,7 +374,7 @@
 				let go urlinfo = Just . maybeToList <$> addUrlFile addunlockedmatcher dlopts url urlinfo f
 				if relaxedOption (downloadOptions opts)
 					then go Url.assumeUrlExists
-					else Url.withUrlOptions Nothing (Url.getUrlInfo url) >>= \case
+					else Url.withUrlOptionsPromptingCreds Nothing (Url.getUrlInfo url) >>= \case
 						Right urlinfo -> go urlinfo
 						Left err -> do
 							warning (UnquotedString err)
diff --git a/Command/TestRemote.hs b/Command/TestRemote.hs
--- a/Command/TestRemote.hs
+++ b/Command/TestRemote.hs
@@ -1,11 +1,12 @@
 {- git-annex command
  -
- - Copyright 2014-2020 Joey Hess <id@joeyh.name>
+ - Copyright 2014-2026 Joey Hess <id@joeyh.name>
  -
  - Licensed under the GNU AGPL version 3 or higher.
  -}
 
 {-# LANGUAGE RankNTypes, DeriveFunctor, PackageImports, OverloadedStrings #-}
+{-# LANGUAGE CPP #-}
 
 module Command.TestRemote where
 
@@ -38,6 +39,9 @@
 import Test.Tasty
 import Test.Tasty.Runners
 import Test.Tasty.HUnit
+#ifdef WITH_TASTYTAP
+import Test.Tasty.Runners.TAP
+#endif
 import "crypto-api" Crypto.Random
 import qualified Data.ByteString as B
 import qualified Data.ByteString.Lazy as L
@@ -47,14 +51,16 @@
 import qualified Data.List.NonEmpty as NE
 
 cmd :: Command
-cmd = command "testremote" SectionTesting
+cmd = noMessages $ command "testremote" SectionTesting
 	"test transfers to/from a remote"
 	paramRemote (seek <$$> optParser)
 
 data TestRemoteOptions = TestRemoteOptions
 	{ testRemote :: RemoteName
 	, sizeOption :: ByteSize
+	, tapOutput :: Bool
 	, testReadonlyFile :: [FilePath]
+	, ensureReadonlyFile :: Maybe FilePath
 	}
 
 optParser :: CmdParamsDesc -> Parser TestRemoteOptions
@@ -65,11 +71,19 @@
 		<> value (1024 * 1024)
 		<> help "base key size (default 1MiB)"
 		)
+	<*> switch
+		( long "tap"
+		<> help "use TAP output"
+		)
 	<*> many testreadonly
+	<*> optional (option str
+		( long "ensure-readonly" <> metavar paramFile
+		<> help "readonly test object (may be deleted from remote)"
+		))
   where
 	testreadonly = option str
 		( long "test-readonly" <> metavar paramFile
-		<> help "readonly test object"
+		<> help "readonly test object (will not be modified)"
 		)
 
 seek :: TestRemoteOptions -> CommandSeek
@@ -81,15 +95,17 @@
 	cache <- liftIO newRemoteVariantCache
 	r <- either giveup (disableExportTree cache)
 		=<< Remote.byName' (testRemote o)
-	ks <- case testReadonlyFile o of
-		[] -> if Remote.readonly r
-			then giveup "This remote is readonly, so you need to use the --test-readonly option."
-			else do
-				showAction "generating test keys"
-				NE.fromList
-					<$> mapM randKey (keySizes basesz fast)
-		fs -> NE.fromList <$> mapM (getReadonlyKey r . toOsPath) fs
-	let r' = if null (testReadonlyFile o)
+	ks <- if ensureReadonlyFile o == Nothing
+		then case testReadonlyFile o of
+			[] -> if Remote.readonly r
+				then giveup "This remote is readonly, so you need to use the --test-readonly option."
+				else gentestkeys fast
+			fs -> NE.fromList <$> mapM (getReadonlyKey r . toOsPath) fs
+		else gentestkeys fast
+	ensurereadonlyk <- case ensureReadonlyFile o of
+		Nothing -> pure Nothing
+		Just f -> Just <$> getReadonlyKey r (toOsPath f)
+	let r' = if null (testReadonlyFile o) && ensureReadonlyFile o == Nothing
 		then r
 		else r { Remote.readonly = True }
 	let drs = if Remote.readonly r'
@@ -99,13 +115,16 @@
 	let exportr = if Remote.readonly r'
 		then return Nothing
 		else exportTreeVariant cache r'
-	perform drs unavailr exportr ks
+	perform o drs unavailr exportr ks ensurereadonlyk
   where
 	basesz = fromInteger $ sizeOption o
 	si = SeekInput [testRemote o]
+	gentestkeys fast = do
+		showAction "generating test keys"
+		NE.fromList <$> mapM randKey (keySizes basesz fast)
 
-perform :: [Described (Annex (Maybe Remote))] -> Maybe Remote -> Annex (Maybe Remote) -> NE.NonEmpty Key -> CommandPerform
-perform drs unavailr exportr ks = do
+perform :: TestRemoteOptions -> [Described (Annex (Maybe Remote))] -> Maybe Remote -> Annex (Maybe Remote) -> NE.NonEmpty Key -> Maybe Key -> CommandPerform
+perform o drs unavailr exportr ks ensurereadonlyk = do
 	st <- liftIO . newTVarIO =<< (,)
 		<$> Annex.getState id
 		<*> Annex.getRead id
@@ -114,14 +133,25 @@
 		drs
 		(pure unavailr)
 		exportr
-		(NE.map (\k -> Described (desck k) (pure k)) ks)
-	ok <- case tryIngredients [consoleTestReporter] mempty tests of
+		(NE.map mkdesck ks)
+		(mkdesck <$> ensurereadonlyk)
+	ok <- case tryIngredients testingredients mempty tests of
 		Nothing -> error "No tests found!?"
 		Just act -> liftIO act
 	rs <- catMaybes <$> mapM getVal drs
 	next $ cleanup rs (NE.toList ks) ok
   where
 	desck k = unwords [ "key size", show (fromKey keySize k) ]
+	mkdesck k = Described (desck k) (pure k)
+	testingredients =
+		if tapOutput o
+#ifdef WITH_TASTYTAP
+			then [ tapRunner ]
+#else
+			then error "git-annex was built without --tap support"
+#endif
+			else []
+		++ [ consoleTestReporter ]
 
 remoteVariants :: RemoteVariantCache -> Described (Annex Remote) -> Int -> Bool -> [Described (Annex (Maybe Remote))]
 remoteVariants cache dr basesz fast = 
@@ -221,22 +251,31 @@
 	-> Annex (Maybe Remote)
 	-> Annex (Maybe Remote)
 	-> (NE.NonEmpty (Described (Annex Key)))
+	-> (Maybe (Described (Annex Key)))
 	-> [TestTree]
-mkTestTrees runannex mkrs mkunavailr mkexportr mkks = concat $
-	[ [ inOrderTestGroup "unavailable remote" (testUnavailable runannex mkunavailr (getVal (NE.head mkks))) ]
-	, [ inOrderTestGroup (desc mkr mkk) (test runannex (getVal mkr) (getVal mkk)) | mkk <- NE.toList mkks, mkr <- mkrs ]
-	, [ inOrderTestGroup (descexport mkk1 mkk2) (testExportTree runannex mkexportr (getVal mkk1) (getVal mkk2)) | mkk1 <- take 2 (NE.toList mkks), mkk2 <- take 2 (reverse (NE.toList mkks)) ]
-	]
+mkTestTrees runannex mkrs mkunavailr mkexportr mkks mkensurereadonlyk = concat $
+	case mkensurereadonlyk of
+		Nothing ->
+			[ [ inOrderTestGroup "unavailable remote" (testUnavailable runannex mkunavailr (getVal (NE.head mkks))) ]
+			, [ inOrderTestGroup (desc mkr mkk) (test runannex (getVal mkr) (getVal mkk)) | mkk <- NE.toList mkks, mkr <- mkrs ]
+			, [ inOrderTestGroup (descexport mkk1 mkk2) (testExportTree runannex mkexportr (getVal mkk1) (getVal mkk2)) | mkk1 <- take 2 (NE.toList mkks), mkk2 <- take 2 (reverse (NE.toList mkks)) ]
+			]
+		Just ensurereadonlyk ->
+			[ [ inOrderTestGroup (desc mkr mkk) (testEnsureReadOnly runannex (getVal mkr) (getVal mkk) (getVal ensurereadonlyk)) | mkk <- NE.toList mkks, mkr <- mkrs ]
+			]
    where
-	desc r k = intercalate "; " $ map unwords
+	desc r k = combinedescs
 		[ [ getDesc k ]
 		, [ getDesc r ]
+		, map getDesc $ maybeToList mkensurereadonlyk
 		]
-	descexport k1 k2 = intercalate "; " $ map unwords
+	descexport k1 k2 = combinedescs
 		[ [ "exporttree=yes" ]
 		, [ getDesc k1 ]
 		, [ getDesc k2 ]
+		, map getDesc $ maybeToList mkensurereadonlyk
 		]
+	combinedescs = intercalate "; " . map unwords . filter (not . null)
 
 test :: RunAnnex -> Annex (Maybe Remote) -> Annex Key -> [TestTree]
 test runannex mkr mkk =
@@ -306,6 +345,33 @@
 		tryNonAsync (Remote.retrieveKeyFile r k (AssociatedFile Nothing) dest nullMeterUpdate (RemoteVerify r)) >>= \case
 			Right v -> return (True, v)
 			Left _ -> return (False, UnVerified)
+	store r k = Remote.storeKey r k (AssociatedFile Nothing) Nothing nullMeterUpdate
+	remove r k = Remote.removeKey r Nothing k
+
+testEnsureReadOnly :: RunAnnex -> Annex (Maybe Remote) -> Annex Key -> Annex Key -> [TestTree]
+testEnsureReadOnly runannex mkr mkk mkensurereadonlyk =
+	[ check "removeKey when present fails" $ \r _ k ->
+		shouldfail $ runBool (remove r k)
+	, check "removeKey did not remove" $ \r _ k ->
+		present r k True
+	, check "removeKey when not present fails" $ \r k _ ->
+		shouldfail $ runBool (remove r k)
+	, check "storeKey when not present fails" $ \r k _ ->
+		shouldfail $ runBool (store r k)
+	]
+  where
+	check desc a = testCase desc $ do
+		let a' = mkr >>= \case
+			Just r -> do
+				k <- mkk
+				ensurereadonlyk <- mkensurereadonlyk
+				a r k ensurereadonlyk
+			Nothing -> return True
+		runannex a' @? "failed"
+	shouldfail a = tryNonAsync a >>= \case
+		Left _ -> return True
+		Right _ -> return False
+	present r k b = (== Right b) <$> Remote.hasKey r k
 	store r k = Remote.storeKey r k (AssociatedFile Nothing) Nothing nullMeterUpdate
 	remove r k = Remote.removeKey r Nothing k
 
diff --git a/Crypto.hs b/Crypto.hs
--- a/Crypto.hs
+++ b/Crypto.hs
@@ -48,6 +48,7 @@
 import Types.Key
 import Annex.SpecialRemote.Config
 import Utility.Tmp.Dir
+import Utility.Hash.Types
 
 {- The number of bytes of entropy used to generate a Cipher.
  -
@@ -241,7 +242,7 @@
 macWithCipher :: Mac -> Cipher -> S.ByteString -> String
 macWithCipher mac c = macWithCipher' mac (cipherMac c)
 macWithCipher' :: Mac -> S.ByteString -> S.ByteString -> String
-macWithCipher' mac c s = calcMac show mac c s
+macWithCipher' mac c s = calcMac (decodeBS  . hashByteString . digestToHash) mac c s
 
 {- Ensure that macWithCipher' returns the same thing forevermore. -}
 prop_HmacSha1WithCipher_sane :: Bool
diff --git a/P2P/Http/Client.hs b/P2P/Http/Client.hs
--- a/P2P/Http/Client.hs
+++ b/P2P/Http/Client.hs
@@ -65,48 +65,64 @@
 
 p2pHttpClient
 	:: Remote
+	-> Annex Git.Repo
 	-> (String -> Annex a)
 	-> ClientAction a
 	-> Annex a
-p2pHttpClient rmt fallback clientaction = 
-	p2pHttpClientVersions (const True) rmt fallback clientaction >>= \case
+p2pHttpClient rmt reprobeurl fallback clientaction = 
+	p2pHttpClientVersions (const True) rmt reprobeurl fallback clientaction >>= \case
 		Just res -> return res
 		Nothing -> fallback "git-annex HTTP API server is missing an endpoint"
 
 p2pHttpClientVersions
 	:: (ProtocolVersion -> Bool)
 	-> Remote
+	-> Annex Git.Repo
 	-> (String -> Annex a)
 	-> ClientAction a
 	-> Annex (Maybe a)
-p2pHttpClientVersions allowedversion rmt fallback clientaction = do
+p2pHttpClientVersions allowedversion rmt reprobeurl fallback clientaction = do
 	rmtrepo <- getRepo rmt
-	p2pHttpClientVersions' allowedversion rmt rmtrepo fallback clientaction
+	case p2purl of
+		Just p2purl' -> p2pHttpClientVersions' allowedversion p2purl' rmt rmtrepo fallbackreprobe clientaction
+		Nothing -> error "internal"
+  where
+	p2purl = remoteAnnexP2PHttpUrl (gitconfig rmt)
+	-- When unable to speak to the server, re-probe for its url, and
+	-- try again if it changed. This avoids the user needing to manually
+	-- update the remote.name.annexUrl config.
+	fallbackreprobe s = do
+		r <- reprobeurl
+		gc <- Annex.getRemoteGitConfig r
+		case remoteAnnexP2PHttpUrl gc of
+			Just p2purl' | Just p2purl' /= p2purl ->
+				p2pHttpClientVersions' allowedversion p2purl' rmt r fallback clientaction >>= \case
+					Just res -> return res
+					Nothing -> fallback s
+			_ -> fallback s
 
 p2pHttpClientVersions'
 	:: (ProtocolVersion -> Bool)
+	-> P2PHttpUrl
 	-> Remote
 	-> Git.Repo
 	-> (String -> Annex a)
 	-> ClientAction a
 	-> Annex (Maybe a)
-p2pHttpClientVersions' allowedversion rmt rmtrepo fallback clientaction =
-	case p2pHttpBaseUrl <$> remoteAnnexP2PHttpUrl (gitconfig rmt) of
-		Nothing -> error "internal"
-		Just baseurl -> do
-			uo <- getUrlOptions (Just (gitconfig rmt))
-			let clientenv = mkClientEnv (httpManager uo) baseurl
-			let clientenv' = clientenv
-				{ makeClientRequest = \u r -> 
-					applyRequest uo
-						<$> makeClientRequest clientenv u r
-				}
-			ccv <- Annex.getRead Annex.gitcredentialcache
-			Git.CredentialCache cc <- liftIO $ atomically $
-				readTMVar ccv
-			case M.lookup (Git.CredentialBaseURL credentialbaseurl) cc of
-				Nothing -> go clientenv' Nothing False Nothing versions
-				Just cred -> go clientenv' (Just cred) True (credauth cred) versions
+p2pHttpClientVersions' allowedversion p2phttpurl rmt rmtrepo fallback clientaction = do
+	uo <- getUrlOptions (Just (gitconfig rmt))
+	let clientenv = mkClientEnv (httpManager uo) (p2pHttpBaseUrl p2phttpurl)
+	let clientenv' = clientenv
+		{ makeClientRequest = \u r -> 
+			applyRequest uo
+				<$> makeClientRequest clientenv u r
+		}
+	ccv <- Annex.getRead Annex.gitcredentialcache
+	Git.CredentialCache cc <- liftIO $ atomically $
+		readTMVar ccv
+	case M.lookup (Git.CredentialBaseURL credentialbaseurl) cc of
+		Nothing -> go clientenv' Nothing False Nothing versions
+		Just cred -> go clientenv' (Just cred) True (credauth cred) versions
   where
 	versions = filter allowedversion allProtocolVersions
 	go clientenv mcred credcached mauth (v:vs) = do
@@ -151,13 +167,11 @@
 			++ " " ++
 		decodeBS (statusMessage (responseStatusCode resp))
 
-	credentialbaseurl = case remoteAnnexP2PHttpUrl (gitconfig rmt) of
-		Just p2phttpurl 
-			| isP2PHttpSameHost p2phttpurl rmtrepo ->
-				Git.repoLocation rmtrepo
-			| otherwise ->
-				p2pHttpUrlString p2phttpurl
-		Nothing -> error "internal"
+	credentialbaseurl
+		| isP2PHttpSameHost p2phttpurl rmtrepo =
+			Git.repoLocation rmtrepo
+		| otherwise =
+			p2pHttpUrlString p2phttpurl
 
 	credauth cred = do
 		ba <- Git.credentialBasicAuth cred
@@ -239,16 +253,17 @@
 	-> Key
 	-> Annex RemoveResultPlus
 	-> Remote
+	-> Annex Git.Repo
 	-> Annex RemoveResultPlus
-clientRemoveWithProof proof k unabletoremove remote =
+clientRemoveWithProof proof k unabletoremove remote reprobeurl =
 	case safeDropProofEndTime =<< proof of
 		Nothing -> removeanytime
 		Just endtime -> removebefore endtime
   where
-	removeanytime = p2pHttpClient remote giveup (clientRemove k)
+	removeanytime = p2pHttpClient remote reprobeurl giveup (clientRemove k)
 
 	removebefore endtime =
-		p2pHttpClientVersions useversion remote giveup clientGetTimestamp >>= \case
+		p2pHttpClientVersions useversion remote reprobeurl giveup clientGetTimestamp >>= \case
 			Just (GetTimestampResult (Timestamp remotetime)) ->
 				removebefore' endtime remotetime
 			-- Peer is too old to support REMOVE-BEFORE.
@@ -256,7 +271,7 @@
 				
 	removebefore' endtime remotetime =
 		canRemoveBefore endtime remotetime (liftIO getPOSIXTime) >>= \case
-			Just remoteendtime -> p2pHttpClient remote giveup $
+			Just remoteendtime -> p2pHttpClient remote reprobeurl giveup $
 				clientRemoveBefore k (Timestamp remoteendtime)
 			Nothing -> unabletoremove
 	
diff --git a/P2P/Http/Url.hs b/P2P/Http/Url.hs
--- a/P2P/Http/Url.hs
+++ b/P2P/Http/Url.hs
@@ -30,7 +30,7 @@
 	{ p2pHttpUrlString :: String
 	, p2pHttpBaseUrl :: BaseUrl
 	}
-	deriving (Show)
+	deriving (Show, Eq)
 
 parseP2PHttpUrl :: String -> Maybe P2PHttpUrl
 parseP2PHttpUrl us
diff --git a/Remote/BitTorrent.hs b/Remote/BitTorrent.hs
--- a/Remote/BitTorrent.hs
+++ b/Remote/BitTorrent.hs
@@ -216,7 +216,7 @@
 					withTmpFileIn othertmp (literalOsPath "torrent") $ \f h -> do
 						liftIO $ hClose h
 						resetAnnexFilePerm f
-						ok <- Url.withUrlOptions (Just gc) $ 
+						ok <- Url.withUrlOptionsPromptingCreds (Just gc) $ 
 							Url.download nullMeterUpdate Nothing u f
 						when ok $
 							liftIO $ moveFile f torrent
diff --git a/Remote/Bup.hs b/Remote/Bup.hs
--- a/Remote/Bup.hs
+++ b/Remote/Bup.hs
@@ -1,6 +1,6 @@
 {- Using bup as a remote.
  -
- - Copyright 2011-2022 Joey Hess <id@joeyh.name>
+ - Copyright 2011-2026 Joey Hess <id@joeyh.name>
  -
  - Licensed under the GNU AGPL version 3 or higher.
  -}
@@ -41,8 +41,23 @@
 import Utility.Metered
 import Types.ProposedAccepted
 
-type BupRepo = String
+data BupRepo
+	= BupRepoPath FilePath
+	| BupRepoRemote String
 
+parseBupRepo :: String -> BupRepo
+parseBupRepo s
+	| ':' `elem` s = BupRepoRemote s
+	| otherwise = BupRepoPath s
+
+serializeBupRepo :: BupRepo -> String
+serializeBupRepo (BupRepoPath p) = p
+serializeBupRepo (BupRepoRemote r) = r
+
+bupRepoLocal :: BupRepo -> Bool
+bupRepoLocal (BupRepoPath _) = True
+bupRepoLocal (BupRepoRemote _ ) = False
+
 remote :: RemoteType
 remote = specialRemoteType $ RemoteType
 	{ typename = "bup"
@@ -67,7 +82,7 @@
 	c <- parsedRemoteConfig remote rc
 	bupr <- liftIO $ bup2GitRemote buprepo
 	cst <- remoteCost gc c $
-		if bupLocal buprepo
+		if bupRepoLocal buprepo
 			then nearlyCheapRemoteCost
 			else expensiveRemoteCost
 	(u', bupr') <- getBupUUID bupr u
@@ -86,7 +101,7 @@
 		, removeKey = removeKeyDummy
 		, lockContent = Nothing
 		, checkPresent = checkPresentDummy
-		, checkPresentCheap = bupLocal buprepo
+		, checkPresentCheap = bupRepoLocal buprepo
 		, exportActions = exportUnsupported
 		, importActions = importUnsupported
 		, exportImportActions = exportImportUnsupported
@@ -97,18 +112,21 @@
 		, config = c
 		, getRepo = return r
 		, gitconfig = gc
-		, localpath = if bupLocal buprepo && not (null buprepo)
-			then Just (toOsPath buprepo)
-			else Nothing
+		, localpath = case buprepo of
+			BupRepoPath p | not (null p) -> Just (toOsPath p)
+			_ -> Nothing
 		, remotetype = remote
-		, availability = if null buprepo
-			then pure LocallyAvailable
-			else checkPathAvailability (bupLocal buprepo) (toOsPath buprepo)
+		, availability = case buprepo of
+			BupRepoPath p
+				| null p -> pure LocallyAvailable
+				| otherwise ->
+					checkPathAvailability True (toOsPath p)
+			BupRepoRemote _ -> pure GloballyAvailable
 		, readonly = False
 		, appendonly = False
 		, untrustworthy = False
 		, mkUnavailable = return Nothing
-		, getInfo = return [("repo", buprepo)]
+		, getInfo = return [("repo", serializeBupRepo buprepo)]
 		, claimUrl = Nothing
 		, checkUrl = Nothing
 		, remoteStateHandle = rs
@@ -124,15 +142,17 @@
 		(checkKey bupr')
 		this
   where
-	buprepo = fromMaybe (giveup "missing buprepo") $ remoteAnnexBupRepo gc
+	buprepo = maybe (giveup "missing buprepo") parseBupRepo $
+		remoteAnnexBupRepo gc
 
 bupSetup :: SetupStage -> Maybe UUID -> RemoteName -> Maybe CredPair -> RemoteConfig -> RemoteGitConfig -> Annex (RemoteConfig, UUID)
 bupSetup ss mu _ _ c gc = do
 	u <- maybe (liftIO genUUID) return mu
 
 	-- verify configuration is sane
-	let buprepo = maybe (giveup "Specify buprepo=") fromProposedAccepted $
-		M.lookup buprepoField c
+	let buprepo = maybe (giveup "Specify buprepo=") 
+		(parseBupRepo . fromProposedAccepted)
+		(M.lookup buprepoField c)
 	(c', _encsetup) <- encryptionSetup ss c gc
 
 	-- bup init will create the repository.
@@ -144,13 +164,15 @@
 
 	-- The buprepo is stored in git config, as well as this repo's
 	-- persistent state, so it can vary between hosts.
-	gitConfigSpecialRemote u c' [("buprepo", buprepo)]
+	gitConfigSpecialRemote u c' [("buprepo", serializeBupRepo buprepo)]
 
 	return (c', u)
 
 bupParams :: String -> BupRepo -> [CommandParam] -> [CommandParam]
-bupParams command buprepo params = 
-	Param command : [Param "-r", Param buprepo] ++ params
+bupParams command (BupRepoPath buprepo) params = 
+	Param "-d" : Param buprepo : Param command : params
+bupParams command (BupRepoRemote buprepo) params = 
+	Param command : Param "-r" : Param buprepo : params
 
 bup :: String -> BupRepo -> [CommandParam] -> Annex Bool
 bup command buprepo params = do
@@ -295,19 +317,18 @@
 			Right r' -> return (toUUID $ Git.Config.get configkeyUUID mempty r', r')
 			Left _ -> return (NoUUID, r)
 
-{- Converts a bup remote path spec into a Git.Repo. There are some
- - differences in path representation between git and bup. -}
+{- Converts a BupRepo into a Git.Repo. There are some
+ - differences in representation between git and bup. -}
 bup2GitRemote :: BupRepo -> IO Git.Repo
-bup2GitRemote "" = do
-	-- bup -r "" operates on ~/.bup
+bup2GitRemote (BupRepoPath "") = do
+	-- bup -d "" operates on ~/.bup
 	h <- myHomeDir
 	Git.Construct.fromPath $ toOsPath h </> literalOsPath ".bup"
-bup2GitRemote r
-	| bupLocal r = 
-		if "/" `isPrefixOf` r
-			then Git.Construct.fromPath (toOsPath r)
-			else giveup "please specify an absolute path"
-	| otherwise = Git.Construct.fromUrl False $ "ssh://" ++ host ++ slash dir
+bup2GitRemote (BupRepoPath r)
+	| "/" `isPrefixOf` r = Git.Construct.fromPath (toOsPath r)
+	| otherwise = giveup "please specify an absolute path"
+bup2GitRemote (BupRepoRemote r) = 
+	Git.Construct.fromUrl False $ "ssh://" ++ host ++ slash dir
   where
 	bits = splitc ':' r
 	host = fromMaybe "" $ headMaybe bits
@@ -328,9 +349,6 @@
 	| otherwise = "git-annex-" ++ show (digestToHash (sha2_256 (fromString shown)))
   where
 	shown = serializeKey k
-
-bupLocal :: BupRepo -> Bool
-bupLocal = notElem ':'
 
 {- Bup is not concurrency safe, so use a lock file. Only one writer process
  - should run at a time; multiple readers may run if no writer is running. -}
diff --git a/Remote/External.hs b/Remote/External.hs
--- a/Remote/External.hs
+++ b/Remote/External.hs
@@ -100,9 +100,9 @@
 			importUnsupported
 		return $ Just $ specialRemote c
 			readonlyStorer
-			(retrieveUrl gc)
+			(retrieveUrlReadOnly gc)
 			readonlyRemoveKey
-			(checkKeyUrl gc)
+			(checkKeyUrlReadOnly gc)
 			rmt
 	| otherwise = do
 		c <- parsedRemoteConfig remote rc
@@ -335,7 +335,7 @@
 				| k == k' -> result $ Left $
 					respErrorMessage "TRANSFER" errmsg
 			TRANSFER_RETRIEVE_URL k' url
-				| k == k' -> getResult $ retrieveUrl' gc url dest k p
+				| k == k' -> getResult $ retrieveUrl gc url dest k p
 			DELEGATE ps -> getResult $ do
 				delegate <- getDelegateRemote external ps
 				_ <- retrieveKeyFile delegate k
@@ -373,7 +373,7 @@
 				| k' == k -> result $ Left $
 					respErrorMessage "CHECKPRESENT" errmsg
 			CHECKPRESENT_URL k' url
-				| k == k' -> checkKeyUrl' gc k url
+				| k == k' -> checkKeyUrl gc k url
 			DELEGATE ps -> Just $ do
 				delegate <- getDelegateRemote external ps
 				Result . Right <$> checkPresent delegate k
@@ -432,7 +432,7 @@
 		TRANSFER_FAILURE Download k' errmsg
 			| k == k' -> result $ Left $ respErrorMessage "TRANSFER" errmsg
 		TRANSFER_RETRIEVE_URL k' url
-			| k == k' -> Just $ Result <$> retrieveUrl' gc url dest k p
+			| k == k' -> Just $ Result <$> retrieveUrl gc url dest k p
 		DELEGATE ps -> getResult $ do
 			delegate <- getDelegateRemote external ps
 			_ <- retrieveExport (exportActions delegate) k loc dest p
@@ -466,7 +466,7 @@
 		RETRIEVEIMPORT_FAILURE errmsg -> 
 			result $ Left $ respErrorMessage "RETRIEVEIMPORT" errmsg
 		RETRIEVEIMPORT_URL url -> getResult $ do
-			retrieveUrl' gc url dest UnknownSize p >>= \case
+			retrieveUrl gc url dest UnknownSize p >>= \case
 				Right () -> Right <$> either pure id gk
 				Left msg -> pure (Left msg)
 		DELEGATE ps -> getResult $ do
@@ -522,7 +522,7 @@
 			| k' == k -> result $ Left $
 				respErrorMessage srequest errmsg
 		CHECKPRESENT_URL k' url
-			| k == k' -> checkKeyUrl' gc k url
+			| k == k' -> checkKeyUrl gc k url
 		DELEGATE ps -> Just $ do
 			delegate <- getDelegateRemote external ps
 			Result . Right <$> delegateaction delegate k loc
@@ -864,7 +864,7 @@
 				liftIO $ atomically $ do
 					l <- takeTMVar cleanupv
 					putTMVar cleanupv (removeTmpFile tmpf:l)
-				res <- withUrlOptions (Just gc) $
+				res <- withUrlOptionsPromptingCreds (Just gc) $
 					downloadUrl' False UnknownSize 
 						nullMeterUpdate Nothing [url]
 						tmpf
@@ -1206,15 +1206,15 @@
   where
 	mkmulti (u, s, f) = (u, s, toOsPath f)
 
-retrieveUrl :: RemoteGitConfig -> Retriever
-retrieveUrl gc = fileRetriever' $ \f k p iv -> do
+retrieveUrlReadOnly :: RemoteGitConfig -> Retriever
+retrieveUrlReadOnly gc = fileRetriever' $ \f k p iv -> do
 	us <- getWebUrls k
 	unlessM (withUrlOptions (Just gc) $ downloadUrl True k p iv us f) $
 		giveup downloadFailed
 
-retrieveUrl' :: MeterSize sizer => RemoteGitConfig -> URLString -> OsPath -> sizer -> MeterUpdate -> Annex (Either String ())
-retrieveUrl' gc url dest sizer p = 
-	withUrlOptions (Just gc) $ \uo ->
+retrieveUrl :: MeterSize sizer => RemoteGitConfig -> URLString -> OsPath -> sizer -> MeterUpdate -> Annex (Either String ())
+retrieveUrl gc url dest sizer p = 
+	withUrlOptionsPromptingCreds (Just gc) $ \uo ->
 		downloadUrl' False sizer p Nothing [url] dest uo >>= return . \case
 			Left msg -> Left msg
 			Right True -> Right ()
@@ -1223,14 +1223,14 @@
 downloadFailed :: String
 downloadFailed = "failed to download content"
 
-checkKeyUrl :: RemoteGitConfig -> CheckPresent
-checkKeyUrl gc k = do
+checkKeyUrlReadOnly :: RemoteGitConfig -> CheckPresent
+checkKeyUrlReadOnly gc k = do
 	us <- getWebUrls k
 	anyM (\u -> withUrlOptions (Just gc) $ checkBoth u (fromKey keySize k)) us
 
-checkKeyUrl' :: RemoteGitConfig -> Key -> URLString -> Maybe (Annex (ResponseHandlerResult (Either String Bool)))
-checkKeyUrl' gc k url = 
-	Just $ withUrlOptions (Just gc) $ \uo ->
+checkKeyUrl :: RemoteGitConfig -> Key -> URLString -> Maybe (Annex (ResponseHandlerResult (Either String Bool)))
+checkKeyUrl gc k url = 
+	Just $ withUrlOptionsPromptingCreds (Just gc) $ \uo ->
 		Result <$> checkBoth' url (fromKey keySize k) uo
 
 getWebUrls :: Key -> Annex [URLString]
diff --git a/Remote/Git.hs b/Remote/Git.hs
--- a/Remote/Git.hs
+++ b/Remote/Git.hs
@@ -73,6 +73,7 @@
 import Messages.Progress
 
 import Control.Concurrent
+import Control.Concurrent.STM
 import qualified Data.Map as M
 import qualified Data.Set as S
 import qualified Data.List.NonEmpty as NE
@@ -377,10 +378,12 @@
 						setremote (setConfig . annexUrlConfigKey) u
 					_ -> noop
 				return r'
-			Left err -> do
-				set_ignore "not usable by git-annex" False
-				warning $ UnquotedString $ configurl (Git.repoLocationUserVisible r) ++ " " ++ err
-				return r
+			Left err
+				| hasuuid -> return r
+				| otherwise -> do
+					set_ignore "not usable by git-annex" False
+					warning $ UnquotedString $ configurl (Git.repoLocationUserVisible r) ++ " " ++ err
+					return r
 
 	configlist_failed = set_ignore "does not have git-annex installed" True
 	
@@ -411,7 +414,7 @@
 	 - it if allowed. However, if that fails, still return the read
 	 - git config. -}
 	readlocalannexconfig = do
-		let check = do
+		let checker = do
 			Annex.BranchState.disableUpdate
 			catchNonAsync (autoInitialize noop (pure [])) $ \e ->
 				warning $ UnquotedString $ "Remote " ++ Git.repoDescribe r ++
@@ -421,7 +424,7 @@
 		if autoinit
 			then do
 				s <- newLocal r'
-				liftIO $ Annex.eval s $ check
+				liftIO $ Annex.eval s $ checker
 					`finally` quiesce True
 			else liftIO $ Git.Config.read r'
 		
@@ -468,13 +471,13 @@
 	inAnnex' repo rmt st key
 
 inAnnex' :: Git.Repo -> Remote -> State -> Key -> Annex Bool
-inAnnex' repo rmt st@(State connpool duc _ _ _ _) key
+inAnnex' repo rmt st@(State connpool duc _ _ _ _ _) key
 	| isP2PHttp rmt = checkp2phttp
 	| Git.repoIsHttp repo = checkhttp
 	| Git.repoIsUrl repo = checkremote
 	| otherwise = checklocal
   where
-	checkp2phttp = p2pHttpClient rmt giveup (clientCheckPresent key)
+	checkp2phttp = p2pHttpClient rmt (p2pHttpReprobe st rmt) giveup (clientCheckPresent key)
 	checkhttp = do
 		gc <- Annex.getGitConfig
 		Url.withUrlOptionsPromptingCreds (Just (gitconfig rmt)) $ \uo -> 
@@ -512,9 +515,9 @@
 	dropKey' repo r st proof key
 
 dropKey' :: Git.Repo -> Remote -> State -> Maybe SafeDropProof -> Key -> Annex ()
-dropKey' repo r st@(State connpool duc _ _ _ _) proof key
+dropKey' repo r st@(State connpool duc _ _ _ _ _) proof key
 	| isP2PHttp r = 
-		clientRemoveWithProof proof key unabletoremove r >>= \case
+		clientRemoveWithProof proof key unabletoremove r (p2pHttpReprobe st r) >>= \case
 			RemoveResultPlus True fanoutuuids ->
 				storefanout fanoutuuids
 			RemoveResultPlus False fanoutuuids -> do
@@ -561,12 +564,12 @@
 	lockKey' repo r st key callback
 
 lockKey' :: Git.Repo -> Remote -> State -> Key -> (VerifiedCopy -> Annex r) -> Annex r
-lockKey' repo r st@(State connpool duc _ _ _ _) key callback
+lockKey' repo r st@(State connpool duc _ _ _ _ _) key callback
 	| isP2PHttp r = do	
 		showLocking r
-		p2pHttpClient r giveup (clientLockContent key) >>= \case
+		p2pHttpClient r (p2pHttpReprobe st r) giveup (clientLockContent key) >>= \case
 			LockResult True (Just lckid) ->
-				p2pHttpClient r failedlock $
+				p2pHttpClient r (p2pHttpReprobe st r) failedlock $
 					clientKeepLocked lckid (uuid r)
 						failedlock callback
 			_ -> failedlock
@@ -596,7 +599,7 @@
 	copyFromRemote'' repo r st key file dest meterupdate vc
 
 copyFromRemote'' :: Git.Repo -> Remote -> State -> Key -> AssociatedFile -> OsPath -> MeterUpdate -> VerifyConfig -> Annex Verification
-copyFromRemote'' repo r st@(State connpool _ _ _ _ _) key af dest meterupdate vc
+copyFromRemote'' repo r st@(State connpool _ _ _ _ _ _) key af dest meterupdate vc
 	| isP2PHttp r = copyp2phttp
 	| Git.repoIsHttp repo = verifyKeyContentIncrementally vc key $ \iv -> do
 		gc <- Annex.getGitConfig
@@ -609,8 +612,8 @@
 		hardlink <- wantHardLink
 		-- run copy from perspective of remote
 		onLocalFast st $ Annex.Content.prepSendAnnex' key Nothing >>= \case
-			Just (object, _sz, check) -> do
-				let checksuccess = check >>= \case
+			Just (object, _sz, checker) -> do
+				let checksuccess = checker >>= \case
 					Just err -> giveup err
 					Nothing -> return True
 				copier <- mkFileCopier hardlink st
@@ -642,7 +645,7 @@
 					_ -> return p
 				let consumer = meteredWrite' p' 
 					(writeVerifyChunk iv h)
-				p2pHttpClient r giveup (clientGet key af consumer startsz) >>= \case
+				p2pHttpClient r (p2pHttpReprobe st r) giveup (clientGet key af consumer startsz) >>= \case
 					Valid -> return ()
 					Invalid -> giveup "Transfer failed"
 
@@ -672,7 +675,7 @@
 	copyToRemote' repo r st key af o meterupdate
 
 copyToRemote' :: Git.Repo -> Remote -> State -> Key -> AssociatedFile -> Maybe OsPath -> MeterUpdate -> Annex ()
-copyToRemote' repo r st@(State connpool duc _ _ _ _) key af o meterupdate
+copyToRemote' repo r st@(State connpool duc _ _ _ _ _) key af o meterupdate
 	| isP2PHttp r = prepsendwith copyp2phttp
 	| not $ Git.repoIsUrl repo = ifM duc
 		( guardUsable repo (giveup "cannot access remote") $ commitOnCleanup repo r st $
@@ -695,11 +698,11 @@
 
 	failedsend = giveup "failed to send content to remote"
 
-	copylocal (object, sz, check) = do
-		-- The check action is going to be run in
+	copylocal (object, sz, checker) = do
+		-- The checker action is going to be run in
 		-- the remote's Annex, but it needs access to the local
 		-- Annex monad's state.
-		checkio <- Annex.withCurrentState check
+		checkerio <- Annex.withCurrentState checker
 		u <- getUUID
 		hardlink <- wantHardLink
 		-- run copy from perspective of remote
@@ -709,7 +712,7 @@
 				let verify = RemoteVerify r
 				copier <- mkFileCopier hardlink st
 				let rsp = RetrievalAllKeysSecure
-				let checksuccess = liftIO checkio >>= \case
+				let checksuccess = liftIO checkerio >>= \case
 					Just err -> giveup err
 					Nothing -> return True
 				logStatusAfter NoLiveUpdate key $ Annex.Content.getViaTmp rsp verify key (Just sz) $ \dest ->
@@ -719,18 +722,18 @@
 		unless res $
 			failedsend
 
-	copyp2phttp (object, sz, check) =
-		let check' = check >>= \case
+	copyp2phttp (object, sz, checker) =
+		let checker' = checker >>= \case
 			Just s -> do
 				warning (UnquotedString s)
 				return False
 			Nothing -> return True
-		in p2pHttpClient r (const $ pure $ PutOffsetResultPlus (Offset 0)) (clientPutOffset key) >>= \case
+		in p2pHttpClient r (p2pHttpReprobe st r) (const $ pure $ PutOffsetResultPlus (Offset 0)) (clientPutOffset key) >>= \case
 			PutOffsetResultPlus (offset@(Offset (P2P.Offset n))) ->
 				metered (Just meterupdate) key bwlimit $ \_ p -> do
 					let p' = offsetMeterUpdate p (BytesProcessed n)
-					res <- p2pHttpClient r giveup $
-						clientPut p' key (Just offset) af object sz check' False
+					res <- p2pHttpClient r (p2pHttpReprobe st r) giveup $
+						clientPut p' key (Just offset) af object sz checker' False
 					case res of
 						PutResultPlus False fanoutuuids -> do
 							storefanout fanoutuuids
@@ -786,7 +789,7 @@
  - when possible.
  -}
 onLocal :: State -> Annex a -> Annex a
-onLocal (State _ _ _ _ _ lra) = onLocal' lra
+onLocal (State _ _ _ _ _ _ lra) = onLocal' lra
 
 onLocalRepo :: Remote -> Git.Repo -> Annex a -> Annex a
 onLocalRepo r repo a = do
@@ -865,32 +868,32 @@
 -- done. Also returns Verified if the key's content is verified while
 -- copying it.
 mkFileCopier :: Bool -> State -> Annex FileCopier
-mkFileCopier remotewanthardlink (State _ _ copycowtried fastcopy _ _) = do
+mkFileCopier remotewanthardlink (State _ _ copycowtried _ fastcopy _ _) = do
 	localwanthardlink <- wantHardLink
 	let linker = \src dest -> R.createLink (fromOsPath src) (fromOsPath dest) >> return True
 	if remotewanthardlink || localwanthardlink
-		then return $ \src dest k p check verifyconfig ->
+		then return $ \src dest k p checker verifyconfig ->
 			ifM (liftIO (catchBoolIO (linker src dest)))
-				( ifM check
+				( ifM checker
 					( return (True, Verified)
 					, do
 						verificationOfContentFailed dest
 						return (False, UnVerified)
 					)
-				, copier src dest k p check verifyconfig
+				, copier src dest k p checker verifyconfig
 				)
 		else return copier
   where
-	copier src dest k p check verifyconfig = do
+	copier src dest k p checker verifyconfig = do
 		iv <- startVerifyKeyContentIncrementally verifyconfig k
 		liftIO (fileCopier copycowtried fastcopy src dest p iv) >>= \case
-			Copied -> ifM check
+			Copied -> ifM checker
 				( finishVerifyKeyContentIncrementally iv
 				, do
 					verificationOfContentFailed dest
 					return (False, UnVerified)
 				)
-			CopiedCoW -> unVerified check
+			CopiedCoW -> unVerified checker
 
 {- Normally the UUID of a local repository is checked at startup,
  - but annex-checkuuid config can prevent that. To avoid getting
@@ -899,25 +902,28 @@
  - This returns False when the repository UUID is not as expected. -}
 type DeferredUUIDCheck = Annex Bool
 
-data State = State Ssh.P2PShellConnectionPool DeferredUUIDCheck CopyCoWTried FastCopy (Annex (Git.Repo, GitConfig)) LocalRemoteAnnex
+type P2pHttpReprobed = TMVar Bool
 
+data State = State Ssh.P2PShellConnectionPool DeferredUUIDCheck CopyCoWTried P2pHttpReprobed FastCopy (Annex (Git.Repo, GitConfig)) LocalRemoteAnnex
+
 getRepoFromState :: State -> Annex Git.Repo
-getRepoFromState (State _ _ _ _ a _) = fst <$> a
+getRepoFromState (State _ _ _ _ _ a _) = fst <$> a
 
 #ifndef mingw32_HOST_OS
 {- The config of the remote git repository, cached for speed. -}
 getGitConfigFromState :: State -> Annex GitConfig
-getGitConfigFromState (State _ _ _ _ a _) = snd <$> a
+getGitConfigFromState (State _ _ _ _ _ a _) = snd <$> a
 #endif
 
 mkState :: Git.Repo -> UUID -> RemoteGitConfig -> Annex State
 mkState r u gc = do
 	pool <- Ssh.mkP2PShellConnectionPool
 	copycowtried <- liftIO newCopyCoWTried
+	p2phttpretried <- liftIO $ newTMVarIO False
 	fastcopy <- getFastCopy gc
 	lra <- mkLocalRemoteAnnex r gc
 	(duc, getrepo) <- go
-	return $ State pool duc copycowtried fastcopy getrepo lra
+	return $ State pool duc copycowtried p2phttpretried fastcopy getrepo lra
   where
 	go
 		| remoteAnnexCheckUUID gc = return
@@ -1062,8 +1068,6 @@
 	-- Git remotes that are gcrypt or git-lfs special remotes cannot
 	-- proxy. Local git remotes cannot proxy either because
 	-- git-annex-shell is not used to access a local git url.
-	-- Proxing is also yet supported for remotes using P2P
-	-- addresses.
 	canproxy gc r
 		| isP2PHttp' gc = True
 		| remoteAnnexGitLFS gc = False
@@ -1077,3 +1081,14 @@
 isP2PHttp' :: RemoteGitConfig -> Bool
 isP2PHttp' = isJust . remoteAnnexP2PHttpUrl
 
+-- Re-read the config of the remote to detect a change to
+-- its annex.url, and update the cached remote.name.annexUrl.
+p2pHttpReprobe :: State -> Remote -> Annex Git.Repo
+p2pHttpReprobe (State _ _ _ reprobed _ _ _) rmt =
+	ifM (liftIO (atomically (takeTMVar reprobed)))
+		( getRepo rmt
+		, do
+			r <- getRepo rmt
+			tryGitConfigRead (gitconfig rmt) False r True
+		)
+	`finally` liftIO (atomically $ putTMVar reprobed True)
diff --git a/Remote/Helper/ExportImport.hs b/Remote/Helper/ExportImport.hs
--- a/Remote/Helper/ExportImport.hs
+++ b/Remote/Helper/ExportImport.hs
@@ -452,20 +452,15 @@
 			else retrieveWithoutContentIdentifier $
 				retrieveFromExport getlocs k af dest p
 	
-	retrieveFromImport getlocs ciddbv k af dest p = do
+	retrieveFromImport getlocs ciddbv k _af dest p = do
 		cids <- getkeycids ciddbv k
-		if not (null cids)
-			then getlocs $ \loc ->
-				-- retrieveImport does not guarantee that
-				-- the file it retrieves has the content
-				-- identifier, so it must be strongly
-				-- verified.
-				stronglyverify $
-					snd <$> retrieveImport (importActions r) loc cids dest (Left k) p
-			-- In case a content identifier is somehow missing,
-			-- try this instead.
-			else retrieveWithoutContentIdentifier $
-				retrieveFromExport getlocs k af dest p
+		getlocs $ \loc ->
+			-- retrieveImport does not guarantee that
+			-- the file it retrieves corresponds to any 
+			-- content identifier, so it must be strongly
+			-- verified.
+			stronglyverify $
+				snd <$> retrieveImport (importActions r) loc cids dest (Left k) p
 
 	retrieveWithoutContentIdentifier a
 		| isexport = a
diff --git a/Remote/HttpAlso.hs b/Remote/HttpAlso.hs
--- a/Remote/HttpAlso.hs
+++ b/Remote/HttpAlso.hs
@@ -134,7 +134,7 @@
 
 downloadAction :: RemoteGitConfig -> OsPath -> MeterUpdate -> Maybe IncrementalVerifier -> ((URLString -> Annex (Either String ())) -> Annex (Either String ())) -> Annex ()
 downloadAction gc dest p iv run =
-	Url.withUrlOptions (Just gc) $ \uo ->
+	Url.withUrlOptionsPromptingCreds (Just gc) $ \uo ->
 		run (\url -> Url.download' p iv url dest uo)
 			>>= either giveup (const (return ()))
 
@@ -144,7 +144,7 @@
 
 checkKey' :: RemoteGitConfig -> Key -> URLString -> Annex (Either String Bool)
 checkKey' gc key url = 
-	Url.withUrlOptions (Just gc) $ Url.checkBoth' url (fromKey keySize key)
+	Url.withUrlOptionsPromptingCreds (Just gc) $ Url.checkBoth' url (fromKey keySize key)
 
 checkPresentExportHttpAlso :: RemoteGitConfig -> Maybe URLString -> Key -> ExportLocation -> Annex Bool
 checkPresentExportHttpAlso gc baseurl key loc =
diff --git a/Remote/Mask.hs b/Remote/Mask.hs
--- a/Remote/Mask.hs
+++ b/Remote/Mask.hs
@@ -166,12 +166,17 @@
 mkMaskedRemote c gc u = do
 	v <- liftIO $ newTMVarIO Nothing
 	return $ MaskedRemote $ 
-		liftIO (atomically (takeTMVar v)) >>= \case
-			Just maskedremote -> return maskedremote
-			Nothing -> do
-				maskedremote <- findMaskedRemote c gc u
-				liftIO $ atomically $ putTMVar v (Just maskedremote)
-				return maskedremote
+		liftIO (atomically (takeTMVar v)) >>= \d ->
+			go v d `onException` restore v
+  where
+	go v (Just maskedremote) = do
+		liftIO $ atomically $ putTMVar v (Just maskedremote)
+		return maskedremote
+	go v Nothing = do
+		maskedremote <- findMaskedRemote c gc u
+		liftIO $ atomically $ putTMVar v (Just maskedremote)
+		return maskedremote
+	restore v = liftIO (atomically (void (tryPutTMVar v Nothing)))
 
 findMaskedRemote :: RemoteConfig -> RemoteGitConfig -> UUID -> Annex Remote
 findMaskedRemote c gc myuuid = case remoteAnnexMask gc of
diff --git a/Remote/Web.hs b/Remote/Web.hs
--- a/Remote/Web.hs
+++ b/Remote/Web.hs
@@ -141,7 +141,7 @@
 			)
 	dl (us, ytus) = do
 		iv <- startVerifyKeyContentIncrementally vc key
-		ifM (Url.withUrlOptions (Just gc) $ downloadUrl True key p iv (map fst us) dest)
+		ifM (Url.withUrlOptionsPromptingCreds (Just gc) $ downloadUrl True key p iv (map fst us) dest)
 			( finishVerifyKeyContentIncrementally iv >>= \case
 				(True, v) -> postdl v
 				(False, _) -> dl ([], ytus)
@@ -193,7 +193,7 @@
 	case downloader of
 		YoutubeDownloader -> youtubeDlCheck u'
 		_ -> catchMsgIO $
-			Url.withUrlOptions (Just gc) $
+			Url.withUrlOptionsPromptingCreds (Just gc) $
 				Url.checkBoth u' (fromKey keySize key)
   where
 	firsthit [] miss _ = return miss
diff --git a/Remote/WebDAV.hs b/Remote/WebDAV.hs
--- a/Remote/WebDAV.hs
+++ b/Remote/WebDAV.hs
@@ -417,12 +417,12 @@
 
 data DavHandle = DavHandle DAVContext DavUser DavPass URLString
 
-type DavHandleVar = TMVar (Either (Annex (Either String DavHandle)) (Either String DavHandle))
+type DavHandleVar = TVar (Either (Annex (Either String DavHandle)) (Either String DavHandle))
 
 {- Prepares a DavHandle for later use. Does not connect to the server or do
  - anything else expensive. -}
 mkDavHandleVar :: ParsedRemoteConfig -> RemoteGitConfig -> UUID -> Annex DavHandleVar
-mkDavHandleVar c gc u = liftIO $ newTMVarIO $ Left $ do
+mkDavHandleVar c gc u = liftIO $ newTVarIO $ Left $ do
 	mcreds <- getCreds c gc u
 	case (mcreds, configUrl c) of
 		(Just (user, pass), Just baseurl) -> do
@@ -431,16 +431,12 @@
 			return (Right h)
 		_ -> return $ Left "webdav credentials not available"
 
-{- Concurrent actions are allowed to run at the same time with the same
- - DavHandle, so any use of eg setDepth will affect other actions. -}
 withDavHandle :: DavHandleVar -> (DavHandle -> Annex a) -> Annex a
-withDavHandle hv a = liftIO (atomically (takeTMVar hv)) >>= \case
-	Right hdl -> do
-		liftIO $ atomically $ putTMVar hv (Right hdl)
-		either giveup a hdl
+withDavHandle hv a = liftIO (readTVarIO hv) >>= \case
+	Right hdl -> either giveup a hdl
 	Left mkhdl -> do
 		hdl <- mkhdl
-		liftIO $ atomically $ putTMVar hv (Right hdl)
+		liftIO $ atomically $ writeTVar hv (Right hdl)
 		either giveup a hdl
 
 goDAV :: DavHandle -> DAVT IO a -> IO a
diff --git a/Test.hs b/Test.hs
--- a/Test.hs
+++ b/Test.hs
@@ -79,7 +79,7 @@
 import qualified Utility.InodeCache
 import qualified Utility.Matcher
 import qualified Utility.Hash
-import qualified Utility.HMAC
+import qualified Utility.Hash.HMAC
 import qualified Utility.Scheduled
 import qualified Utility.Scheduled.QuickCheck
 import qualified Utility.HumanTime
@@ -98,7 +98,7 @@
 
 optParser :: Parser TestOptions
 optParser = TestOptions
-	<$> snd (tastyParser (tests 1 False defaulttos))
+	<$> snd (tastyParser defaulttos (tests 1 False defaulttos))
 	<*> switch
 		( long "keep-failures"
 		<> help "preserve repositories on test failure"
@@ -121,6 +121,10 @@
 		( long "test-debug"
 		<> help "show debug messages for commands run by test suite"
 		)
+	<*> switch
+		( long "tap"
+		<> help "use TAP output"
+		)
 	<*> cmdParams "non-options are for internal use only"
   where
 	parseconfigvalue s = case break (== '=') s of
@@ -136,11 +140,12 @@
 		, concurrentJobs = Nothing
 		, testGitConfig = mempty
 		, testDebug = False
+		, tapOutput = False
 		, internalData = mempty
 		}
 
 runner :: TestOptions -> IO ()
-runner opts = parallelTestRunner opts tests
+runner opts = testRunner opts tests
 
 tests :: Int -> Bool -> TestOptions -> [TestTree]
 tests numparts crippledfilesystem opts = 
@@ -202,7 +207,7 @@
   where
 	combos = concat
 		[ Utility.Hash.props_hashes_stable
-		, Utility.HMAC.props_macs_stable
+		, Utility.Hash.HMAC.props_macs_stable
 		]
 
 testRemotes :: TestTree
@@ -261,7 +266,7 @@
 			cv <- annexeval cache
 			liftIO $ atomically $ putTMVar v
 				(r, (unavailr, (exportr, (ks, cv))))
-	go getv = Command.TestRemote.mkTestTrees runannex mkrs mkunavailr mkexportr (NE.fromList mkks)
+	go getv = Command.TestRemote.mkTestTrees runannex mkrs mkunavailr mkexportr (NE.fromList mkks) Nothing
 	  where
 		runannex = inmainrepo . annexeval
 		mkrs = if testvariants
diff --git a/Test/Framework.hs b/Test/Framework.hs
--- a/Test/Framework.hs
+++ b/Test/Framework.hs
@@ -21,6 +21,9 @@
 import Test.Tasty.Ingredients.Rerun
 import Test.Tasty.Ingredients.ConsoleReporter
 import qualified Test.Tasty.Patterns.Types as TP
+#ifdef WITH_TASTYTAP
+import Test.Tasty.Runners.TAP
+#endif
 import Options.Applicative.Types
 import Control.Concurrent
 import Control.Concurrent.Async
@@ -772,6 +775,23 @@
 		\_ _ _ pid -> exitWith =<< waitForProcess pid
 runFakeSsh ps = error $ "fake ssh option parse error: " ++ show ps
 
+testRunner :: TestOptions -> (Int -> Bool -> TestOptions -> [TestTree]) -> IO ()
+testRunner opts mkts
+	| fakeSsh opts = runFakeSsh (internalData opts)
+	| tapOutput opts = 
+#ifdef WITH_TASTYTAP
+		parallelTestRunner 1 opts mkts
+#else
+		error "git-annex was built without --tap support"
+#endif
+	| otherwise = do
+		numjobs <- case concurrentJobs opts of
+			Just NonConcurrent -> pure 1
+			Just (Concurrent n) -> pure n
+			Just ConcurrentPerCpu -> getNumProcessors
+			Nothing -> getNumProcessors
+		parallelTestRunner numjobs opts mkts
+
 {- Tests each TestTree in parallel, and exits with success/failure.
  -
  - Tasty supports parallel tests, but this does not use it, because
@@ -782,17 +802,8 @@
  - leave open are closed before finalCleanup is run at the end. This
  - prevents some failures to clean up after the test suite.
  -}
-parallelTestRunner :: TestOptions -> (Int -> Bool -> TestOptions -> [TestTree]) -> IO ()
-parallelTestRunner opts mkts = do
-	numjobs <- case concurrentJobs opts of
-		Just NonConcurrent -> pure 1
-		Just (Concurrent n) -> pure n
-		Just ConcurrentPerCpu -> getNumProcessors
-		Nothing -> getNumProcessors
-	parallelTestRunner' numjobs opts mkts
-
-parallelTestRunner' :: Int -> TestOptions -> (Int -> Bool -> TestOptions -> [TestTree]) -> IO ()
-parallelTestRunner' numjobs opts mkts
+parallelTestRunner :: Int -> TestOptions -> (Int -> Bool -> TestOptions -> [TestTree]) -> IO ()
+parallelTestRunner numjobs opts mkts
 	| fakeSsh opts = runFakeSsh (internalData opts)
 	| otherwise = go =<< Utility.Env.getEnv subenv
   where
@@ -808,6 +819,11 @@
 		then 1
 		else numjobs * 2
 
+	mkts' crippledfilesystem
+		| tapOutput opts = 
+			[topLevelTestGroup $ mkts 1 crippledfilesystem opts]
+		| otherwise = mkts numparts crippledfilesystem opts
+
 	worker rs nvar a = do
 		(n, m) <- atomically $ do
 			(n, m) <- readTVar nvar
@@ -819,7 +835,12 @@
 				r <- a n
 				worker (r:rs) nvar a
 	
-	summarizeresults a = do
+	summarizeresults a
+		| tapOutput opts = do
+			_ <- a
+			return ()
+		| otherwise = summarizeresults' a
+	summarizeresults' a = do
 		starttime <- getCurrentTime
 		(numts, exitcodes) <- a
 		duration <- Utility.HumanTime.durationSince starttime
@@ -846,8 +867,8 @@
 			<$> Annex.Init.probeCrippledFileSystem'
 				(toOsPath tmpdir)
 				Nothing Nothing False
-		let ts = mkts numparts crippledfilesystem opts
-		let warnings = fst (tastyParser ts)
+		let ts = mkts' crippledfilesystem
+		let warnings = fst (tastyParser opts ts)
 		unless (null warnings) $ do
 			hPutStrLn stderr "warnings from tasty:"
 			mapM_ (hPutStrLn stderr) warnings
@@ -855,9 +876,11 @@
 		args <- getArgs
 		pp <- fromOsPath <$> Annex.Path.programPath
 		termcolor <- hSupportsANSIColor stdout
-		let ps = if useColor (lookupOption tastyopts) termcolor
-			then "--color=always":args
-			else "--color=never":args
+		let ps = if tapOutput opts
+			then args
+			else if useColor (lookupOption tastyopts) termcolor
+				then "--color=always":args
+				else "--color=never":args
 		let runone n = do
 			let subdir = fromOsPath $ toOsPath tmpdir </> toOsPath (show n)
 			ensuredir subdir
@@ -875,9 +898,11 @@
 	go (Just subenvval) = case readish subenvval of
 		Nothing -> error ("Bad " ++ subenv)
 		Just (n, crippledfilesystem) -> setTestEnv $ do
-			let ts = mkts numparts crippledfilesystem opts
-			let t = topLevelTestGroup [ ts !! (n - 1) ]
-			case tryIngredients ingredients tastyopts t of
+			let ts = mkts' crippledfilesystem
+			let t = if tapOutput opts
+				then ts !! 0
+				else topLevelTestGroup [ ts !! (n - 1) ]
+			case tryIngredients (ingredients opts) tastyopts t of
 				Nothing -> error "No tests found!?"
 				Just act -> ifM act
 					( exitSuccess
@@ -901,14 +926,20 @@
 initTestsName :: String
 initTestsName = "Init Tests"
 
-tastyParser :: [TestTree] -> ([String], Parser Test.Tasty.Options.OptionSet)
-tastyParser ts = suiteOptionParser ingredients (topLevelTestGroup ts)
+tastyParser :: TestOptions -> [TestTree] -> ([String], Parser Test.Tasty.Options.OptionSet)
+tastyParser opts ts = suiteOptionParser (ingredients opts) (topLevelTestGroup ts)
 
-ingredients :: [Ingredient]
-ingredients =
-	[ listingTests
-	, rerunningTests [consoleTestReporter]
-	]
+ingredients :: TestOptions -> [Ingredient]
+ingredients opts =
+	listingTests :
+#ifdef WITH_TASTYTAP
+	if tapOutput opts
+		then [ tapRunner ]
+		else []
+#else
+	[]
+#endif
+	++ [ rerunningTests [consoleTestReporter] ]
 
 -- Prior to tasty 1.5.4, testGroup ran in order.
 #if ! MIN_VERSION_tasty(1,5,4)
diff --git a/Types/Crypto.hs b/Types/Crypto.hs
--- a/Types/Crypto.hs
+++ b/Types/Crypto.hs
@@ -20,7 +20,7 @@
 	calcMac,
 ) where
 
-import Utility.HMAC
+import Utility.Hash.HMAC
 import Utility.Gpg (KeyIds(..))
 
 import Data.Typeable
diff --git a/Types/Remote.hs b/Types/Remote.hs
--- a/Types/Remote.hs
+++ b/Types/Remote.hs
@@ -481,8 +481,8 @@
 	-- key.
 	, importKey :: a (Maybe (ImportLocation -> ContentIdentifier -> ByteSize -> MeterUpdate -> a (Maybe Key)))
 	-- Like retrieveExportWithContentIdentifier, but does not
-	-- need to guarantee that the file it retrieves has one
-	-- of the requested ContentIdentifiers.
+	-- need to guarantee that the file it retrieves corresponds
+	-- to any of the listed ContentIdentifiers.
 	, retrieveImport
 		:: ImportLocation
 		-> [ContentIdentifier]
diff --git a/Types/Test.hs b/Types/Test.hs
--- a/Types/Test.hs
+++ b/Types/Test.hs
@@ -1,6 +1,6 @@
 {- git-annex test data types.
  -
- - Copyright 2011-2022 Joey Hess <id@joeyh.name>
+ - Copyright 2011-2026 Joey Hess <id@joeyh.name>
  -
  - Licensed under the GNU AGPL version 3 or higher.
  -}
@@ -20,6 +20,7 @@
 	, concurrentJobs :: Maybe Concurrency
 	, testGitConfig :: [(ConfigKey, ConfigValue)]
 	, testDebug :: Bool
+	, tapOutput :: Bool
 	, internalData :: CmdParams
 	}
 
diff --git a/Utility/HMAC.hs b/Utility/HMAC.hs
deleted file mode 100644
--- a/Utility/HMAC.hs
+++ /dev/null
@@ -1,56 +0,0 @@
-{- Convenience wrapper around crypton's HMACs
- -
- - Copyright 2013-2026 Joey Hess <id@joeyh.name>
- -
- - License: BSD-2-clause
- -}
-
-{-# LANGUAGE PackageImports #-}
-{-# LANGUAGE RankNTypes #-}
-
-module Utility.HMAC (
-	Mac(..),
-	calcMac,
-	Digest,
-	props_macs_stable,
-) where
-
-import qualified Data.ByteString as S
-import qualified Data.Text as T
-import qualified Data.Text.Encoding as T
-import "crypton" Crypto.MAC.HMAC hiding (Context)
-import "crypton" Crypto.Hash
-
-data Mac = HmacSha1 | HmacSha224 | HmacSha256 | HmacSha384 | HmacSha512
-	deriving (Eq)
-
-calcMac
-	:: (forall a. Digest a -> t)    -- ^ applied to MAC'ed message
-	-> Mac          -- ^ MAC
-	-> S.ByteString -- ^ secret key
-	-> S.ByteString -- ^ message
-	-> t
-calcMac f mac = case mac of
-	HmacSha1   -> use SHA1
-	HmacSha224 -> use SHA224
-	HmacSha256 -> use SHA256
-	HmacSha384 -> use SHA384
-	HmacSha512 -> use SHA512
-  where
-	use alg k m = f (hmacGetDigest (hmacWitnessAlg alg k m))
-
-	hmacWitnessAlg :: HashAlgorithm a => a -> S.ByteString -> S.ByteString -> HMAC a
-	hmacWitnessAlg _ = hmac
-
--- Check that all the MACs continue to produce the same.
-props_macs_stable :: [(String, Bool)]
-props_macs_stable = map (\(desc, mac, result) -> (desc ++ " stable", calcMac show mac key msg == result))
-	[ ("HmacSha1", HmacSha1, "46b4ec586117154dacd49d664e5d63fdc88efb51")
-	, ("HmacSha224", HmacSha224, "4c1f774863acb63b7f6e9daa9b5c543fa0d5eccf61e3ffc3698eacdd")
-	, ("HmacSha256", HmacSha256, "f9320baf0249169e73850cd6156ded0106e2bb6ad8cab01b7bbbebe6d1065317")
-	, ("HmacSha384", HmacSha384, "3d10d391bee2364df2c55cf605759373e1b5a4ca9355d8f3fe42970471eca2e422a79271a0e857a69923839015877fc6")
-	, ("HmacSha512", HmacSha512, "114682914c5d017dfe59fdc804118b56a3a652a0b8870759cf9e792ed7426b08197076bf7d01640b1b0684df79e4b67e37485669e8ce98dbab60445f0db94fce")
-	]
-  where
-	key = T.encodeUtf8 $ T.pack "foo"
-	msg = T.encodeUtf8 $ T.pack "bar"
diff --git a/Utility/Hash/Crypton.hs b/Utility/Hash/Crypton.hs
--- a/Utility/Hash/Crypton.hs
+++ b/Utility/Hash/Crypton.hs
@@ -5,7 +5,7 @@
  - License: BSD-2-clause
  -}
 
-{-# LANGUAGE BangPatterns, PackageImports #-}
+{-# LANGUAGE BangPatterns, PackageImports, CPP #-}
 {-# LANGUAGE RankNTypes #-}
 
 module Utility.Hash.Crypton (
@@ -64,12 +64,17 @@
 	Context,
 	mkIncrementalHasher,
 	mkIncrementalVerifier,
+	hashDigest,
 ) where
 
 import qualified Data.ByteString as S
 import qualified Data.ByteString.Lazy as L
 import Data.IORef
+#if MIN_VERSION_crypton(1,1,0)
+import qualified "ram" Data.ByteArray as BA
+#else
 import qualified "memory" Data.ByteArray as BA
+#endif
 import "crypton" Crypto.Hash
 
 import Utility.Hash.Types
diff --git a/Utility/Hash/HMAC.hs b/Utility/Hash/HMAC.hs
new file mode 100644
--- /dev/null
+++ b/Utility/Hash/HMAC.hs
@@ -0,0 +1,73 @@
+{- Convenience wrapper for HMACs
+ -
+ - Copyright 2013-2026 Joey Hess <id@joeyh.name>
+ -
+ - License: BSD-2-clause
+ -}
+
+{-# LANGUAGE PackageImports #-}
+{-# LANGUAGE RankNTypes #-}
+{-# LANGUAGE OverloadedStrings, CPP #-}
+
+module Utility.Hash.HMAC (
+	Mac(..),
+	calcMac,
+	props_macs_stable,
+) where
+
+import Utility.Hash.Types
+import qualified Data.ByteString as S
+import qualified Data.Text as T
+import qualified Data.Text.Encoding as T
+#ifdef WITH_BOTAN
+import Botan.Low.MAC
+import Botan.Low.Hash hiding (HashDigest)
+import System.IO.Unsafe (unsafePerformIO)
+#else
+import Utility.Hash.Crypton (hashDigest)
+import "crypton" Crypto.MAC.HMAC hiding (Context)
+import "crypton" Crypto.Hash
+#endif
+
+data Mac = HmacSha1 | HmacSha224 | HmacSha256 | HmacSha384 | HmacSha512
+	deriving (Eq)
+
+calcMac
+	:: (HashDigest -> t) -- ^ applied to MAC'ed message
+	-> Mac               -- ^ MAC
+	-> S.ByteString      -- ^ secret key
+	-> S.ByteString      -- ^ message
+	-> t
+calcMac f mac = case mac of
+	HmacSha1   -> use SHA1
+	HmacSha224 -> use SHA224
+	HmacSha256 -> use SHA256
+	HmacSha384 -> use SHA384
+	HmacSha512 -> use SHA512
+  where
+#ifdef WITH_BOTAN
+	use alg k m = unsafePerformIO $ do
+		maccer <- macInit (hmac alg)
+		macSetKey maccer k
+		macUpdate maccer m
+		auth <- macFinal maccer
+		return (f (HashDigest auth))
+#else
+	use alg k m = f (hashDigest (hmacGetDigest (hmacWitnessAlg alg k m)))
+
+	hmacWitnessAlg :: HashAlgorithm a => a -> S.ByteString -> S.ByteString -> HMAC a
+	hmacWitnessAlg _ = hmac
+#endif
+
+-- Check that all the MACs continue to produce the same.
+props_macs_stable :: [(String, Bool)]
+props_macs_stable = map (\(desc, mac, result) -> (desc ++ " stable", calcMac digestToHash mac key msg == result))
+	[ ("HmacSha1", HmacSha1, "46b4ec586117154dacd49d664e5d63fdc88efb51")
+	, ("HmacSha224", HmacSha224, "4c1f774863acb63b7f6e9daa9b5c543fa0d5eccf61e3ffc3698eacdd")
+	, ("HmacSha256", HmacSha256, "f9320baf0249169e73850cd6156ded0106e2bb6ad8cab01b7bbbebe6d1065317")
+	, ("HmacSha384", HmacSha384, "3d10d391bee2364df2c55cf605759373e1b5a4ca9355d8f3fe42970471eca2e422a79271a0e857a69923839015877fc6")
+	, ("HmacSha512", HmacSha512, "114682914c5d017dfe59fdc804118b56a3a652a0b8870759cf9e792ed7426b08197076bf7d01640b1b0684df79e4b67e37485669e8ce98dbab60445f0db94fce")
+	]
+  where
+	key = T.encodeUtf8 $ T.pack "foo"
+	msg = T.encodeUtf8 $ T.pack "bar"
diff --git a/Utility/Hash/Types.hs b/Utility/Hash/Types.hs
--- a/Utility/Hash/Types.hs
+++ b/Utility/Hash/Types.hs
@@ -11,7 +11,6 @@
 module Utility.Hash.Types where
 
 import qualified Data.ByteString as S
-import "memory" Data.ByteArray
 import qualified "memory" Data.ByteArray.Encoding as BAE
 import Data.String
 import Control.DeepSeq
@@ -33,14 +32,10 @@
 instance NFData Hash
 
 -- the raw hash digest
-newtype HashDigest = HashDigest S.ByteString
+newtype HashDigest = HashDigest { hashDigestByteString :: S.ByteString }
 	deriving (Eq, Generic)
 
 digestToHash :: HashDigest -> Hash
 digestToHash (HashDigest d) = Hash $ BAE.convertToBase BAE.Base16 d
 
 instance NFData HashDigest
-
-instance ByteArrayAccess HashDigest where
-	length (HashDigest d) = length d
-	withByteArray (HashDigest d) = withByteArray d
diff --git a/Utility/QuickCheck.hs b/Utility/QuickCheck.hs
--- a/Utility/QuickCheck.hs
+++ b/Utility/QuickCheck.hs
@@ -8,6 +8,7 @@
 {-# OPTIONS_GHC -fno-warn-orphans #-}
 {-# OPTIONS_GHC -fno-warn-tabs #-}
 {-# LANGUAGE TypeSynonymInstances #-}
+{-# LANGUAGE CPP #-}
 
 module Utility.QuickCheck
 	( module X
@@ -23,7 +24,9 @@
 import Data.Ratio
 import Data.Char
 import System.Posix.Types
+#if ! MIN_VERSION_QuickCheck(2,17,0)
 import Data.List.NonEmpty (NonEmpty(..))
+#endif
 
 {- A String, but Arbitrary is limited to ascii.
  -
@@ -78,8 +81,10 @@
 instance Arbitrary FileOffset where
 	arbitrary = nonNegative arbitrarySizedIntegral
 
+#if ! MIN_VERSION_QuickCheck(2,17,0)
 instance Arbitrary l => Arbitrary (NonEmpty l) where
 	arbitrary = (:|) <$> arbitrary <*> arbitrary
+#endif
 
 nonNegative :: (Num a, Ord a) => Gen a -> Gen a
 nonNegative g = g `suchThat` (>= 0)
diff --git a/git-annex.cabal b/git-annex.cabal
--- a/git-annex.cabal
+++ b/git-annex.cabal
@@ -1,5 +1,5 @@
 Name: git-annex
-Version: 10.20260901
+Version: 10.20261005
 Cabal-Version: 1.12
 License: AGPL-3
 Maintainer: Joey Hess <id@joeyh.name>
@@ -194,6 +194,10 @@
   Description: Enable XXH3 support
   Default: False
 
+Flag TastyTap
+  Description: Use tasty-tap to support git-annex test TAP output
+  Default: True
+
 source-repository head
   type: git
   location: https://git.joeyh.name/git/git-annex.git/
@@ -289,10 +293,8 @@
    servant-client-core,
    warp (>= 3.2.8),
    warp-tls (>= 3.2.2),
-   crypton,
    crypton-connection (>= 0.4.3),
    crypton-x509-store,
-   tls,
    aws (>= 0.24.1)
   CC-Options: -Wall
   GHC-Options: -Wall -fno-warn-tabs  -Wincomplete-uni-patterns
@@ -309,11 +311,16 @@
      ram (< 0.21.0),
      persistent (>= 2.13.3) && (< 2.15.0.0),
      warp (< 3.4.11),
-     magic (<= 1.1)
+     magic (<= 1.1),
+     crypton (< 1.1.0),
+     tls (< 2.4.4)
   else
     Build-Depends:
      base (>= 4.18.2.1 && < 5),
-     persistent (>= 2.13.3)
+     persistent (>= 2.13.3),
+     crypton,
+     ram,
+     tls
 
   -- Fully optimize for production.
   if flag(Production)
@@ -364,6 +371,10 @@
     Other-Modules:
       Utility.Hash.XXH3
 
+  if flag(TastyTap)
+    Build-Depends: tasty-tap
+    CPP-Options: -DWITH_TASTYTAP
+
   if (os(windows))
     Build-Depends:
       Win32 (>= 2.13.4.0),
@@ -1117,7 +1128,7 @@
     Utility.Hash.Crypton
     Utility.Hash.Incremental
     Utility.Hash.Types
-    Utility.HMAC
+    Utility.Hash.HMAC
     Utility.HtmlDetect
     Utility.HumanNumber
     Utility.HumanTime
diff --git a/stack-NoLLMDependencies.yaml b/stack-NoLLMDependencies.yaml
--- a/stack-NoLLMDependencies.yaml
+++ b/stack-NoLLMDependencies.yaml
@@ -29,3 +29,5 @@
 - yesod-form-1.7.9.2
 - yesod-static-1.6.1.2
 - magic-1.1
+- crypton-1.0.6
+- tls-2.1.8
