001/* 002 * Licensed to the Apache Software Foundation (ASF) under one 003 * or more contributor license agreements. See the NOTICE file 004 * distributed with this work for additional information 005 * regarding copyright ownership. The ASF licenses this file 006 * to you under the Apache License, Version 2.0 (the 007 * "License"); you may not use this file except in compliance 008 * with the License. You may obtain a copy of the License at 009 * 010 * http://www.apache.org/licenses/LICENSE-2.0 011 * 012 * Unless required by applicable law or agreed to in writing, 013 * software distributed under the License is distributed on an 014 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 015 * KIND, either express or implied. See the License for the 016 * specific language governing permissions and limitations 017 * under the License. 018 */ 019package org.eclipse.aether.internal.impl; 020 021import javax.inject.Inject; 022import javax.inject.Named; 023import javax.inject.Singleton; 024 025import java.nio.file.Files; 026import java.nio.file.Path; 027import java.util.Collections; 028import java.util.HashMap; 029import java.util.Map; 030import java.util.Properties; 031import java.util.Set; 032import java.util.TreeSet; 033import java.util.concurrent.ConcurrentHashMap; 034 035import org.eclipse.aether.ConfigurationProperties; 036import org.eclipse.aether.Keys; 037import org.eclipse.aether.RepositorySystemSession; 038import org.eclipse.aether.artifact.Artifact; 039import org.eclipse.aether.impl.UpdateCheck; 040import org.eclipse.aether.impl.UpdateCheckManager; 041import org.eclipse.aether.impl.UpdatePolicyAnalyzer; 042import org.eclipse.aether.metadata.Metadata; 043import org.eclipse.aether.repository.AuthenticationDigest; 044import org.eclipse.aether.repository.Proxy; 045import org.eclipse.aether.repository.RemoteRepository; 046import org.eclipse.aether.resolution.ResolutionErrorPolicy; 047import org.eclipse.aether.spi.io.PathProcessor; 048import org.eclipse.aether.transfer.ArtifactNotFoundException; 049import org.eclipse.aether.transfer.ArtifactTransferException; 050import org.eclipse.aether.transfer.MetadataNotFoundException; 051import org.eclipse.aether.transfer.MetadataTransferException; 052import org.eclipse.aether.util.ConfigUtils; 053import org.slf4j.Logger; 054import org.slf4j.LoggerFactory; 055 056import static java.util.Objects.requireNonNull; 057 058/** 059 */ 060@Singleton 061@Named 062public class DefaultUpdateCheckManager implements UpdateCheckManager { 063 064 private static final Logger LOGGER = LoggerFactory.getLogger(DefaultUpdateCheckManager.class); 065 066 private static final String UPDATED_KEY_SUFFIX = ".lastUpdated"; 067 068 private static final String ERROR_KEY_SUFFIX = ".error"; 069 070 private static final String NOT_FOUND = ""; 071 072 // instance bound private key 073 static final Object SESSION_CHECKS = Keys.of(new Object() { 074 @Override 075 public String toString() { 076 return "updateCheckManager.checks"; 077 } 078 }); 079 080 // instance bound private key 081 static final Object SESSION_NOT_FOUNDS = Keys.of(new Object() { 082 @Override 083 public String toString() { 084 return "updateCheckManager.notFounds"; 085 } 086 }); 087 088 /** 089 * Manages the session state, i.e. influences if the same download requests to artifacts/metadata will happen 090 * multiple times within the same RepositorySystemSession. If "enabled" will enable the session state. If "bypass" 091 * will enable bypassing (i.e. store all artifact ids/metadata ids which have been updates but not evaluating 092 * those). All other values lead to disabling the session state completely. 093 * 094 * @configurationSource {@link RepositorySystemSession#getConfigProperties()} 095 * @configurationType {@link java.lang.String} 096 * @configurationDefaultValue {@link #DEFAULT_SESSION_STATE} 097 */ 098 public static final String CONFIG_PROP_SESSION_STATE = 099 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}