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}