View Javadoc
1   /*
2    * Licensed to the Apache Software Foundation (ASF) under one
3    * or more contributor license agreements.  See the NOTICE file
4    * distributed with this work for additional information
5    * regarding copyright ownership.  The ASF licenses this file
6    * to you under the Apache License, Version 2.0 (the
7    * "License"); you may not use this file except in compliance
8    * with the License.  You may obtain a copy of the License at
9    *
10   *   http://www.apache.org/licenses/LICENSE-2.0
11   *
12   * Unless required by applicable law or agreed to in writing,
13   * software distributed under the License is distributed on an
14   * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15   * KIND, either express or implied.  See the License for the
16   * specific language governing permissions and limitations
17   * under the License.
18   */
19  package org.eclipse.aether.internal.impl;
20  
21  import javax.inject.Inject;
22  import javax.inject.Named;
23  import javax.inject.Singleton;
24  
25  import java.nio.file.Files;
26  import java.nio.file.Path;
27  import java.util.Collections;
28  import java.util.HashMap;
29  import java.util.Map;
30  import java.util.Properties;
31  import java.util.Set;
32  import java.util.TreeSet;
33  import java.util.concurrent.ConcurrentHashMap;
34  
35  import org.eclipse.aether.ConfigurationProperties;
36  import org.eclipse.aether.Keys;
37  import org.eclipse.aether.RepositorySystemSession;
38  import org.eclipse.aether.artifact.Artifact;
39  import org.eclipse.aether.impl.UpdateCheck;
40  import org.eclipse.aether.impl.UpdateCheckManager;
41  import org.eclipse.aether.impl.UpdatePolicyAnalyzer;
42  import org.eclipse.aether.metadata.Metadata;
43  import org.eclipse.aether.repository.AuthenticationDigest;
44  import org.eclipse.aether.repository.Proxy;
45  import org.eclipse.aether.repository.RemoteRepository;
46  import org.eclipse.aether.resolution.ResolutionErrorPolicy;
47  import org.eclipse.aether.spi.io.PathProcessor;
48  import org.eclipse.aether.transfer.ArtifactNotFoundException;
49  import org.eclipse.aether.transfer.ArtifactTransferException;
50  import org.eclipse.aether.transfer.MetadataNotFoundException;
51  import org.eclipse.aether.transfer.MetadataTransferException;
52  import org.eclipse.aether.util.ConfigUtils;
53  import org.slf4j.Logger;
54  import org.slf4j.LoggerFactory;
55  
56  import static java.util.Objects.requireNonNull;
57  
58  /**
59   */
60  @Singleton
61  @Named
62  public class DefaultUpdateCheckManager implements UpdateCheckManager {
63  
64      private static final Logger LOGGER = LoggerFactory.getLogger(DefaultUpdateCheckManager.class);
65  
66      private static final String UPDATED_KEY_SUFFIX = ".lastUpdated";
67  
68      private static final String ERROR_KEY_SUFFIX = ".error";
69  
70      private static final String NOT_FOUND = "";
71  
72      // instance bound private key
73      static final Object SESSION_CHECKS = Keys.of(new Object() {
74          @Override
75          public String toString() {
76              return "updateCheckManager.checks";
77          }
78      });
79  
80      // instance bound private key
81      static final Object SESSION_NOT_FOUNDS = Keys.of(new Object() {
82          @Override
83          public String toString() {
84              return "updateCheckManager.notFounds";
85          }
86      });
87  
88      /**
89       * Manages the session state, i.e. influences if the same download requests to artifacts/metadata will happen
90       * multiple times within the same RepositorySystemSession. If "enabled" will enable the session state. If "bypass"
91       * will enable bypassing (i.e. store all artifact ids/metadata ids which have been updates but not evaluating
92       * those). All other values lead to disabling the session state completely.
93       *
94       * @configurationSource {@link RepositorySystemSession#getConfigProperties()}
95       * @configurationType {@link java.lang.String}
96       * @configurationDefaultValue {@link #DEFAULT_SESSION_STATE}
97       */
98      public static final String CONFIG_PROP_SESSION_STATE =
99              ConfigurationProperties.PREFIX_AETHER + "updateCheckManager.sessionState";
100 
101     public static final String DEFAULT_SESSION_STATE = "enabled";
102 
103     private static final int STATE_ENABLED = 0;
104 
105     private static final int STATE_BYPASS = 1;
106 
107     private static final int STATE_DISABLED = 2;
108 
109     /**
110      * This "last modified" timestamp is used when no local file is present, signaling "first attempt" to cache a file,
111      * but as it is not present, outcome is simply always "go get it".
112      * <p>
113      * Its meaning is "we never downloaded it", so go grab it.
114      */
115     private static final long TS_NEVER = 0L;
116 
117     /**
118      * This "last modified" timestamp is returned by {@link #getLastUpdated(Properties, String)} method when the
119      * timestamp entry is not found (due properties file not present or key not present in properties file, irrelevant).
120      * It means that the cached file (artifact or metadata) is present, but we cannot tell when was it downloaded. In
121      * this case, it is {@link UpdatePolicyAnalyzer} applying in-effect policy, that decide is update (re-download)
122      * needed or not. For example, if policy is "never", we should not re-download the file.
123      * <p>
124      * Its meaning is "we downloaded it, but have no idea when", so let the policy decide its fate.
125      */
126     private static final long TS_UNKNOWN = 1L;
127 
128     private final TrackingFileManager trackingFileManager;
129 
130     private final UpdatePolicyAnalyzer updatePolicyAnalyzer;
131 
132     private final PathProcessor pathProcessor;
133 
134     @Inject
135     public DefaultUpdateCheckManager(
136             TrackingFileManager trackingFileManager,
137             UpdatePolicyAnalyzer updatePolicyAnalyzer,
138             PathProcessor pathProcessor) {
139         this.trackingFileManager = requireNonNull(trackingFileManager, "tracking file manager cannot be null");
140         this.updatePolicyAnalyzer = requireNonNull(updatePolicyAnalyzer, "update policy analyzer cannot be null");
141         this.pathProcessor = requireNonNull(pathProcessor, "path processor cannot be null");
142     }
143 
144     @Override
145     public void checkArtifact(RepositorySystemSession session, UpdateCheck<Artifact, ArtifactTransferException> check) {
146         requireNonNull(session, "session cannot be null");
147         requireNonNull(check, "check cannot be null");
148         final String updatePolicy = check.getArtifactPolicy();
149         if (check.getLocalLastUpdated() != 0
150                 && !isUpdatedRequired(session, check.getLocalLastUpdated(), updatePolicy)) {
151             LOGGER.debug("Skipped remote request for {}, locally installed artifact up-to-date", check.getItem());
152 
153             check.setRequired(false);
154             return;
155         }
156 
157         Artifact artifact = check.getItem();
158         RemoteRepository repository = check.getRepository();
159 
160         Path artifactPath =
161                 requireNonNull(check.getPath(), String.format("The artifact '%s' has no file attached", artifact));
162 
163         boolean fileExists = check.isFileValid() && Files.exists(artifactPath);
164 
165         Path touchPath = getArtifactTouchFile(artifactPath);
166         Properties props = read(touchPath);
167 
168         String updateKey = getUpdateKey(session, artifactPath, repository);
169         String dataKey = getDataKey(repository);
170 
171         String error = getError(props, dataKey);
172 
173         long lastUpdated;
174         if (error == null) {
175             if (fileExists) {
176                 // last update was successful
177                 lastUpdated = pathProcessor.lastModified(artifactPath, 0L);
178             } else {
179                 // this is the first attempt ever
180                 lastUpdated = TS_NEVER;
181             }
182         } else if (error.isEmpty()) {
183             // artifact did not exist
184             lastUpdated = getLastUpdated(props, dataKey);
185         } else {
186             // artifact could not be transferred
187             String transferKey = getTransferKey(session, repository);
188             lastUpdated = getLastUpdated(props, transferKey);
189         }
190 
191         if (lastUpdated == TS_NEVER) {
192             check.setRequired(true);
193         } else if (isAlreadyUpdated(session, updateKey)) {
194             LOGGER.debug("Skipped remote request for {}, already updated during this session", check.getItem());
195 
196             check.setRequired(false);
197             if (error != null) {
198                 check.setException(newException(error, artifact, repository));
199             }
200         } else if (isUpdatedRequired(session, lastUpdated, updatePolicy)) {
201             check.setRequired(true);
202         } else if (fileExists) {
203             LOGGER.debug("Skipped remote request for {}, locally cached artifact up-to-date", check.getItem());
204 
205             check.setRequired(false);
206         } else {
207             int errorPolicy = Utils.getPolicy(session, artifact, repository);
208             int cacheFlag = getCacheFlag(error);
209             if ((errorPolicy & cacheFlag) != 0) {
210                 check.setRequired(false);
211                 check.setException(newException(error, artifact, repository));
212             } else {
213                 check.setRequired(true);
214             }
215         }
216     }
217 
218     private static int getCacheFlag(String error) {
219         if (error == null || error.isEmpty()) {
220             return ResolutionErrorPolicy.CACHE_NOT_FOUND;
221         } else {
222             return ResolutionErrorPolicy.CACHE_TRANSFER_ERROR;
223         }
224     }
225 
226     private ArtifactTransferException newException(String error, Artifact artifact, RemoteRepository repository) {
227         if (error == null || error.isEmpty()) {
228             return new ArtifactNotFoundException(
229                     artifact,
230                     repository,
231                     artifact
232                             + " was not found in " + repository.getUrl()
233                             + " during a previous attempt. This failure was"
234                             + " cached in the local repository and"
235                             + " resolution is not reattempted until the update interval of " + repository.getId()
236                             + " has elapsed or updates are forced",
237                     true);
238         } else {
239             return new ArtifactTransferException(
240                     artifact,
241                     repository,
242                     artifact + " failed to transfer from "
243                             + repository.getUrl() + " during a previous attempt. This failure"
244                             + " was cached in the local repository and"
245                             + " resolution is not reattempted until the update interval of " + repository.getId()
246                             + " has elapsed or updates are forced. Original error: " + LogSanitizer.sanitize(error),
247                     true);
248         }
249     }
250 
251     @Override
252     public void checkMetadata(RepositorySystemSession session, UpdateCheck<Metadata, MetadataTransferException> check) {
253         requireNonNull(session, "session cannot be null");
254         requireNonNull(check, "check cannot be null");
255         final String updatePolicy = check.getMetadataPolicy();
256         if (check.getLocalLastUpdated() != 0
257                 && !isUpdatedRequired(session, check.getLocalLastUpdated(), updatePolicy)) {
258             LOGGER.debug("Skipped remote request for {} locally installed metadata up-to-date", check.getItem());
259 
260             check.setRequired(false);
261             return;
262         }
263 
264         Metadata metadata = check.getItem();
265         RemoteRepository repository = check.getRepository();
266 
267         Path metadataPath =
268                 requireNonNull(check.getPath(), String.format("The metadata '%s' has no file attached", metadata));
269 
270         boolean fileExists = check.isFileValid() && Files.exists(metadataPath);
271 
272         Path touchPath = getMetadataTouchFile(metadataPath);
273         Properties props = read(touchPath);
274 
275         String updateKey = getUpdateKey(session, metadataPath, repository);
276         String dataKey = getDataKey(metadataPath);
277 
278         String error = getError(props, dataKey);
279 
280         long lastUpdated;
281         if (error == null) {
282             if (fileExists) {
283                 // last update was successful
284                 lastUpdated = getLastUpdated(props, dataKey);
285             } else {
286                 // this is the first attempt ever
287                 lastUpdated = TS_NEVER;
288             }
289         } else if (error.isEmpty()) {
290             // metadata did not exist
291             lastUpdated = getLastUpdated(props, dataKey);
292         } else {
293             // metadata could not be transferred
294             String transferKey = getTransferKey(session, metadataPath, repository);
295             lastUpdated = getLastUpdated(props, transferKey);
296         }
297 
298         if (lastUpdated == TS_NEVER) {
299             check.setRequired(true);
300         } else if (isAlreadyUpdated(session, updateKey)) {
301             LOGGER.debug("Skipped remote request for {}, already updated during this session", check.getItem());
302 
303             check.setRequired(false);
304             if (error != null) {
305                 check.setException(newException(error, metadata, repository));
306             }
307         } else if (isUpdatedRequired(session, lastUpdated, updatePolicy)) {
308             check.setRequired(true);
309         } else if (fileExists) {
310             LOGGER.debug("Skipped remote request for {}, locally cached metadata up-to-date", check.getItem());
311 
312             check.setRequired(false);
313         } else {
314             int errorPolicy = Utils.getPolicy(session, metadata, repository);
315             int cacheFlag = getCacheFlag(error);
316             if ((errorPolicy & cacheFlag) != 0) {
317                 check.setRequired(false);
318                 check.setException(newException(error, metadata, repository));
319             } else {
320                 check.setRequired(true);
321             }
322         }
323     }
324 
325     private MetadataTransferException newException(String error, Metadata metadata, RemoteRepository repository) {
326         if (error == null || error.isEmpty()) {
327             return new MetadataNotFoundException(
328                     metadata,
329                     repository,
330                     metadata + " was not found in "
331                             + repository.getUrl() + " during a previous attempt."
332                             + " This failure was cached in the local repository and"
333                             + " resolution is not be reattempted until the update interval of " + repository.getId()
334                             + " has elapsed or updates are forced",
335                     true);
336         } else {
337             return new MetadataTransferException(
338                     metadata,
339                     repository,
340                     metadata + " failed to transfer from "
341                             + repository.getUrl() + " during a previous attempt."
342                             + " This failure was cached in the local repository and"
343                             + " resolution will not be reattempted until the update interval of " + repository.getId()
344                             + " has elapsed or updates are forced. Original error: " + LogSanitizer.sanitize(error),
345                     true);
346         }
347     }
348 
349     private long getLastUpdated(Properties props, String key) {
350         String value = props.getProperty(key + UPDATED_KEY_SUFFIX, "");
351         try {
352             return (!value.isEmpty()) ? Long.parseLong(value) : TS_UNKNOWN;
353         } catch (NumberFormatException e) {
354             LOGGER.debug("Cannot parse last updated date {}, ignoring it", value, e);
355             return TS_UNKNOWN;
356         }
357     }
358 
359     private String getError(Properties props, String key) {
360         return props.getProperty(key + ERROR_KEY_SUFFIX);
361     }
362 
363     private Path getArtifactTouchFile(Path artifactPath) {
364         return artifactPath.getParent().resolve(artifactPath.getFileName() + UPDATED_KEY_SUFFIX);
365     }
366 
367     private Path getMetadataTouchFile(Path metadataPath) {
368         return metadataPath.getParent().resolve("resolver-status.properties");
369     }
370 
371     private String getDataKey(RemoteRepository repository) {
372         Set<String> mirroredUrls = Collections.emptySet();
373         if (repository.isRepositoryManager()) {
374             mirroredUrls = new TreeSet<>();
375             for (RemoteRepository mirroredRepository : repository.getMirroredRepositories()) {
376                 mirroredUrls.add(normalizeRepoUrl(mirroredRepository.getUrl()));
377             }
378         }
379 
380         StringBuilder buffer = new StringBuilder(1024);
381 
382         buffer.append(normalizeRepoUrl(repository.getUrl()));
383         for (String mirroredUrl : mirroredUrls) {
384             buffer.append('+').append(mirroredUrl);
385         }
386 
387         return buffer.toString();
388     }
389 
390     private String getTransferKey(RepositorySystemSession session, RemoteRepository repository) {
391         return getRepoKey(session, repository);
392     }
393 
394     private String getDataKey(Path metadataPath) {
395         return metadataPath.getFileName().toString();
396     }
397 
398     private String getTransferKey(RepositorySystemSession session, Path metadataPath, RemoteRepository repository) {
399         return metadataPath.getFileName().toString() + '/' + getRepoKey(session, repository);
400     }
401 
402     private String getRepoKey(RepositorySystemSession session, RemoteRepository repository) {
403         StringBuilder buffer = new StringBuilder(128);
404 
405         Proxy proxy = repository.getProxy();
406         if (proxy != null) {
407             buffer.append(AuthenticationDigest.forProxy(session, repository)).append('@');
408             buffer.append(proxy.getHost()).append(':').append(proxy.getPort()).append('>');
409         }
410 
411         buffer.append(AuthenticationDigest.forRepository(session, repository)).append('@');
412 
413         buffer.append(repository.getContentType()).append('-');
414         buffer.append(repository.getId()).append('-');
415         buffer.append(normalizeRepoUrl(repository.getUrl()));
416 
417         return buffer.toString();
418     }
419 
420     private String normalizeRepoUrl(String url) {
421         String result = url;
422         if (url != null && !url.isEmpty() && !url.endsWith("/")) {
423             result = url + '/';
424         }
425         return result;
426     }
427 
428     private String getUpdateKey(RepositorySystemSession session, Path path, RemoteRepository repository) {
429         return path.toAbsolutePath() + "|" + getRepoKey(session, repository);
430     }
431 
432     private int getSessionState(RepositorySystemSession session) {
433         String mode = ConfigUtils.getString(session, DEFAULT_SESSION_STATE, CONFIG_PROP_SESSION_STATE);
434         if (Boolean.parseBoolean(mode) || "enabled".equalsIgnoreCase(mode)) {
435             // perform update check at most once per session, regardless of update policy
436             return STATE_ENABLED;
437         } else if ("bypass".equalsIgnoreCase(mode)) {
438             // evaluate update policy but record update in session to prevent potential future checks
439             return STATE_BYPASS;
440         } else {
441             // no session state at all, always evaluate update policy
442             return STATE_DISABLED;
443         }
444     }
445 
446     private boolean isAlreadyUpdated(RepositorySystemSession session, String updateKey) {
447         if (getSessionState(session) >= STATE_BYPASS) {
448             return false;
449         }
450         Object checkedFiles = session.getData().get(SESSION_CHECKS);
451         if (!(checkedFiles instanceof Map)) {
452             return false;
453         }
454         return ((Map<?, ?>) checkedFiles).containsKey(updateKey);
455     }
456 
457     @SuppressWarnings("unchecked")
458     private void setUpdated(RepositorySystemSession session, String updateKey) {
459         if (getSessionState(session) >= STATE_DISABLED) {
460             return;
461         }
462         Object checkedFiles = session.getData().computeIfAbsent(SESSION_CHECKS, () -> new ConcurrentHashMap<>(256));
463         ((Map<String, Boolean>) checkedFiles).put(updateKey, Boolean.TRUE);
464     }
465 
466     private String getNotFoundKey(Path touchPath, String dataKey) {
467         return touchPath.toAbsolutePath() + "|" + dataKey;
468     }
469 
470     /**
471      * Records not-found markers written during this session so that an ordinary repository fall-through is not
472      * logged as a warning when a sibling repository later provides the artifact.
473      */
474     @SuppressWarnings("unchecked")
475     private void setNotFound(RepositorySystemSession session, String notFoundKey) {
476         Object notFounds = session.getData().computeIfAbsent(SESSION_NOT_FOUNDS, ConcurrentHashMap::newKeySet);
477         ((Set<String>) notFounds).add(notFoundKey);
478     }
479 
480     private boolean isNotFoundInSession(RepositorySystemSession session, String notFoundKey) {
481         Object notFounds = session.getData().get(SESSION_NOT_FOUNDS);
482         return notFounds instanceof Set && ((Set<?>) notFounds).contains(notFoundKey);
483     }
484 
485     private boolean isUpdatedRequired(RepositorySystemSession session, long lastModified, String policy) {
486         return updatePolicyAnalyzer.isUpdatedRequired(session, lastModified, policy);
487     }
488 
489     private Properties read(Path touchPath) {
490         Properties props = trackingFileManager.read(touchPath);
491         return (props != null) ? props : new Properties();
492     }
493 
494     @Override
495     public void touchArtifact(RepositorySystemSession session, UpdateCheck<Artifact, ArtifactTransferException> check) {
496         requireNonNull(session, "session cannot be null");
497         requireNonNull(check, "check cannot be null");
498         Path artifactPath = check.getPath();
499         Path touchPath = getArtifactTouchFile(artifactPath);
500 
501         String updateKey = getUpdateKey(session, artifactPath, check.getRepository());
502         String dataKey = getDataKey(check.getAuthoritativeRepository());
503         String transferKey = getTransferKey(session, check.getRepository());
504 
505         setUpdated(session, updateKey);
506         Map<String, String> staleSiblingNotFounds = Collections.emptyMap();
507         if (check.getException() == null) {
508             staleSiblingNotFounds = getStaleSiblingNotFounds(
509                     session, read(touchPath), touchPath, dataKey, check.getItem(), check.getRepository());
510         } else if (check.getException() instanceof ArtifactNotFoundException) {
511             setNotFound(session, getNotFoundKey(touchPath, dataKey));
512         }
513         Properties props = write(touchPath, dataKey, transferKey, check.getException(), staleSiblingNotFounds);
514 
515         if (Files.exists(artifactPath) && !hasErrors(props)) {
516             trackingFileManager.delete(touchPath);
517         }
518     }
519 
520     /**
521      * Collects, for removal, cached not-found markers left by other repositories for an artifact that has just
522      * been successfully downloaded. A cached not-found silently suppresses any remote contact with the repository
523      * that issued it, so a single spoofed or transient "not found" answer would otherwise durably reroute
524      * resolution of the artifact to whatever lower-prioritized repository serves it. Expiring the marker forces a
525      * confirming re-check of the suppressed repository (subject to the update policy) the next time it is
526      * consulted, and the reroute is surfaced at WARN level instead of happening silently. A marker written during
527      * the current session is the ordinary fall-through between repositories and is logged at DEBUG instead.
528      */
529     private Map<String, String> getStaleSiblingNotFounds(
530             RepositorySystemSession session,
531             Properties props,
532             Path touchPath,
533             String dataKey,
534             Artifact artifact,
535             RemoteRepository repository) {
536         Map<String, String> removals = null;
537         for (Object k : props.keySet()) {
538             String key = k.toString();
539             if (key.endsWith(ERROR_KEY_SUFFIX)) {
540                 String otherDataKey = key.substring(0, key.length() - ERROR_KEY_SUFFIX.length());
541                 if (!otherDataKey.equals(dataKey) && NOT_FOUND.equals(props.getProperty(key))) {
542                     if (isNotFoundInSession(session, getNotFoundKey(touchPath, otherDataKey))) {
543                         LOGGER.debug(
544                                 "{} was downloaded from {} after {} answered not-found earlier in this session;"
545                                         + " not caching that not-found",
546                                 artifact,
547                                 repository.getUrl(),
548                                 otherDataKey);
549                     } else {
550                         LOGGER.warn(
551                                 "{} was downloaded from {} while a not-found cached by a previous session suppresses"
552                                         + " re-checking {}; expiring the cached not-found so that repository is"
553                                         + " checked again on the next resolution",
554                                 artifact,
555                                 repository.getUrl(),
556                                 otherDataKey);
557                     }
558                     if (removals == null) {
559                         removals = new HashMap<>();
560                     }
561                     removals.put(key, null);
562                     removals.put(otherDataKey + UPDATED_KEY_SUFFIX, null);
563                 }
564             }
565         }
566         return removals != null ? removals : Collections.emptyMap();
567     }
568 
569     private boolean hasErrors(Properties props) {
570         for (Object key : props.keySet()) {
571             if (key.toString().endsWith(ERROR_KEY_SUFFIX)) {
572                 return true;
573             }
574         }
575         return false;
576     }
577 
578     @Override
579     public void touchMetadata(RepositorySystemSession session, UpdateCheck<Metadata, MetadataTransferException> check) {
580         requireNonNull(session, "session cannot be null");
581         requireNonNull(check, "check cannot be null");
582         Path metadataPath = check.getPath();
583         Path touchPath = getMetadataTouchFile(metadataPath);
584 
585         String updateKey = getUpdateKey(session, metadataPath, check.getRepository());
586         String dataKey = getDataKey(metadataPath);
587         String transferKey = getTransferKey(session, metadataPath, check.getRepository());
588 
589         setUpdated(session, updateKey);
590         write(touchPath, dataKey, transferKey, check.getException(), Collections.emptyMap());
591     }
592 
593     private Properties write(
594             Path touchPath, String dataKey, String transferKey, Exception error, Map<String, String> extraUpdates) {
595         Map<String, String> updates = new HashMap<>(extraUpdates);
596 
597         String timestamp = Long.toString(System.currentTimeMillis());
598 
599         if (error == null) {
600             updates.put(dataKey + ERROR_KEY_SUFFIX, null);
601             updates.put(dataKey + UPDATED_KEY_SUFFIX, timestamp);
602             updates.put(transferKey + UPDATED_KEY_SUFFIX, null);
603         } else if (error instanceof ArtifactNotFoundException || error instanceof MetadataNotFoundException) {
604             updates.put(dataKey + ERROR_KEY_SUFFIX, NOT_FOUND);
605             updates.put(dataKey + UPDATED_KEY_SUFFIX, timestamp);
606             updates.put(transferKey + UPDATED_KEY_SUFFIX, null);
607         } else {
608             String msg = error.getMessage();
609             if (msg == null || msg.isEmpty()) {
610                 msg = error.getClass().getSimpleName();
611             }
612             // transfer error messages may embed remote-derived bytes: neutralize terminal control characters
613             // before persisting them, as they are re-read and logged on subsequent update checks
614             updates.put(dataKey + ERROR_KEY_SUFFIX, LogSanitizer.sanitize(msg));
615             updates.put(dataKey + UPDATED_KEY_SUFFIX, null);
616             updates.put(transferKey + UPDATED_KEY_SUFFIX, timestamp);
617         }
618 
619         return trackingFileManager.update(touchPath, updates);
620     }
621 }