Skip to content

Commit 17c203e

Browse files
romtsncodex
andcommitted
fix(android): Validate persisted options cache generation
Persist the app update timestamp after writing the options snapshot. Trust cached release, environment, and dist only when the marker identifies the current app installation, preserving launch-specific values without leaking stale metadata across app updates. Co-Authored-By: Codex <noreply@openai.com>
1 parent e017d22 commit 17c203e

5 files changed

Lines changed: 207 additions & 39 deletions

File tree

sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,11 @@ static void initializeIntegrationsAndProcessors(
182182
if (options.getCacheDirPath() != null) {
183183
options.addScopeObserver(new PersistingScopeObserver(options));
184184
options.addOptionsObserver(new PersistingOptionsObserver(options));
185+
final PackageInfo packageInfo = ContextUtils.getPackageInfo(context, buildInfoProvider);
186+
if (packageInfo != null && packageInfo.lastUpdateTime > 0) {
187+
options.addOptionsObserver(
188+
new PersistingOptionsCacheGenerationObserver(options, packageInfo.lastUpdateTime));
189+
}
185190
}
186191

187192
options.addEventProcessor(new DeduplicateMultithreadedEventProcessor(options));

sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java

Lines changed: 66 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -163,12 +163,13 @@ public ApplicationExitInfoEventProcessor(
163163
setDevice(event);
164164

165165
final boolean canUseCurrentOptions = isAppNotUpdated(backfillable);
166+
final boolean optionsCacheForCurrentApp = isOptionsCacheForCurrentApp();
166167

167168
if (!backfillable.shouldEnrich()) {
168-
setRelease(event, canUseCurrentOptions);
169-
setEnvironment(event, canUseCurrentOptions);
170-
setDist(event, canUseCurrentOptions);
171-
setAppVersionAndBuild(event, canUseCurrentOptions);
169+
setRelease(event, canUseCurrentOptions, optionsCacheForCurrentApp);
170+
setEnvironment(event, canUseCurrentOptions, optionsCacheForCurrentApp);
171+
setDist(event, canUseCurrentOptions, optionsCacheForCurrentApp);
172+
setAppVersionAndBuild(event);
172173
options
173174
.getLogger()
174175
.log(
@@ -179,7 +180,7 @@ public ApplicationExitInfoEventProcessor(
179180

180181
backfillScope(event);
181182

182-
backfillOptions(event, canUseCurrentOptions);
183+
backfillOptions(event, canUseCurrentOptions, optionsCacheForCurrentApp);
183184

184185
setStaticValues(event);
185186

@@ -401,17 +402,19 @@ private void setRequest(final @NotNull SentryBaseEvent event) {
401402

402403
// region options persisted values
403404
private void backfillOptions(
404-
final @NotNull SentryEvent event, final boolean canUseCurrentOptions) {
405-
setRelease(event, canUseCurrentOptions);
406-
setEnvironment(event, canUseCurrentOptions);
407-
setDist(event, canUseCurrentOptions);
405+
final @NotNull SentryEvent event,
406+
final boolean canUseCurrentOptions,
407+
final boolean optionsCacheForCurrentApp) {
408+
setRelease(event, canUseCurrentOptions, optionsCacheForCurrentApp);
409+
setEnvironment(event, canUseCurrentOptions, optionsCacheForCurrentApp);
410+
setDist(event, canUseCurrentOptions, optionsCacheForCurrentApp);
408411
setDebugMeta(event);
409412
setSdk(event);
410-
setApp(event, canUseCurrentOptions);
413+
setApp(event);
411414
setOptionsTags(event);
412415
}
413416

414-
private void setApp(final @NotNull SentryBaseEvent event, final boolean canUseCurrentOptions) {
417+
private void setApp(final @NotNull SentryBaseEvent event) {
415418
App app = event.getContexts().getApp();
416419
if (app == null) {
417420
app = new App();
@@ -437,18 +440,11 @@ private void setApp(final @NotNull SentryBaseEvent event, final boolean canUseCu
437440
}
438441

439442
event.getContexts().setApp(app);
440-
setAppVersionAndBuild(event, canUseCurrentOptions);
443+
setAppVersionAndBuild(event);
441444
}
442445

443-
private void setAppVersionAndBuild(
444-
final @NotNull SentryBaseEvent event, final boolean canUseCurrentOptions) {
445-
String release = event.getRelease();
446-
if (release == null) {
447-
release = PersistingOptionsObserver.read(options, RELEASE_FILENAME, String.class);
448-
}
449-
if (release == null && canUseCurrentOptions) {
450-
release = options.getRelease();
451-
}
446+
private void setAppVersionAndBuild(final @NotNull SentryBaseEvent event) {
447+
final String release = event.getRelease();
452448
if (release != null) {
453449
try {
454450
App app = event.getContexts().getApp();
@@ -470,25 +466,30 @@ private void setAppVersionAndBuild(
470466
}
471467

472468
private void setRelease(
473-
final @NotNull SentryBaseEvent event, final boolean canUseCurrentOptions) {
469+
final @NotNull SentryBaseEvent event,
470+
final boolean canUseCurrentOptions,
471+
final boolean optionsCacheForCurrentApp) {
474472
if (event.getRelease() == null) {
475-
String release = PersistingOptionsObserver.read(options, RELEASE_FILENAME, String.class);
476-
if (release == null && canUseCurrentOptions) {
477-
release = options.getRelease();
478-
}
479-
event.setRelease(release);
473+
event.setRelease(
474+
getOption(
475+
RELEASE_FILENAME,
476+
options.getRelease(),
477+
canUseCurrentOptions,
478+
optionsCacheForCurrentApp));
480479
}
481480
}
482481

483482
private void setEnvironment(
484-
final @NotNull SentryBaseEvent event, final boolean canUseCurrentOptions) {
483+
final @NotNull SentryBaseEvent event,
484+
final boolean canUseCurrentOptions,
485+
final boolean optionsCacheForCurrentApp) {
485486
if (event.getEnvironment() == null) {
486-
final String environment =
487-
PersistingOptionsObserver.read(options, ENVIRONMENT_FILENAME, String.class);
488487
event.setEnvironment(
489-
environment != null
490-
? environment
491-
: canUseCurrentOptions ? options.getEnvironment() : null);
488+
getOption(
489+
ENVIRONMENT_FILENAME,
490+
options.getEnvironment(),
491+
canUseCurrentOptions,
492+
optionsCacheForCurrentApp));
492493
}
493494
}
494495

@@ -516,13 +517,14 @@ private void setDebugMeta(final @NotNull SentryBaseEvent event) {
516517
}
517518
}
518519

519-
private void setDist(final @NotNull SentryBaseEvent event, final boolean canUseCurrentOptions) {
520+
private void setDist(
521+
final @NotNull SentryBaseEvent event,
522+
final boolean canUseCurrentOptions,
523+
final boolean optionsCacheForCurrentApp) {
520524
if (event.getDist() == null) {
521-
String dist = PersistingOptionsObserver.read(options, DIST_FILENAME, String.class);
522-
if (dist == null && canUseCurrentOptions) {
523-
dist = options.getDist();
524-
}
525-
event.setDist(dist);
525+
event.setDist(
526+
getOption(
527+
DIST_FILENAME, options.getDist(), canUseCurrentOptions, optionsCacheForCurrentApp));
526528
}
527529
// if there's no user-set dist, fall back to versionCode from the release string
528530
if (event.getDist() == null) {
@@ -540,6 +542,22 @@ private void setDist(final @NotNull SentryBaseEvent event, final boolean canUseC
540542
}
541543
}
542544

545+
private @Nullable String getOption(
546+
final @NotNull String fileName,
547+
final @Nullable String currentValue,
548+
final boolean canUseCurrentOptions,
549+
final boolean optionsCacheForCurrentApp) {
550+
if (canUseCurrentOptions && !optionsCacheForCurrentApp) {
551+
return currentValue;
552+
}
553+
if (!canUseCurrentOptions && optionsCacheForCurrentApp) {
554+
return null;
555+
}
556+
557+
final String persistedValue = PersistingOptionsObserver.read(options, fileName, String.class);
558+
return persistedValue != null ? persistedValue : canUseCurrentOptions ? currentValue : null;
559+
}
560+
543561
private boolean isAppNotUpdated(final @NotNull Backfillable hint) {
544562
final @Nullable Long timestamp;
545563
if (hint instanceof AbnormalExit) {
@@ -559,6 +577,15 @@ private boolean isAppNotUpdated(final @NotNull Backfillable hint) {
559577
&& packageInfo.lastUpdateTime <= timestamp;
560578
}
561579

580+
private boolean isOptionsCacheForCurrentApp() {
581+
final Long cachedLastUpdateTime = PersistingOptionsCacheGenerationObserver.read(options);
582+
final PackageInfo packageInfo = ContextUtils.getPackageInfo(context, buildInfoProvider);
583+
return cachedLastUpdateTime != null
584+
&& packageInfo != null
585+
&& packageInfo.lastUpdateTime > 0
586+
&& cachedLastUpdateTime == packageInfo.lastUpdateTime;
587+
}
588+
562589
private void setSdk(final @NotNull SentryBaseEvent event) {
563590
if (event.getSdk() == null) {
564591
final SdkVersion sdkVersion =
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
package io.sentry.android.core;
2+
3+
import static io.sentry.cache.PersistingOptionsObserver.OPTIONS_CACHE;
4+
5+
import io.sentry.IOptionsObserver;
6+
import io.sentry.SentryOptions;
7+
import io.sentry.protocol.SdkVersion;
8+
import io.sentry.util.FileUtils;
9+
import java.io.File;
10+
import java.io.FileOutputStream;
11+
import java.io.OutputStream;
12+
import java.nio.charset.Charset;
13+
import java.util.Map;
14+
import org.jetbrains.annotations.NotNull;
15+
import org.jetbrains.annotations.Nullable;
16+
17+
final class PersistingOptionsCacheGenerationObserver implements IOptionsObserver {
18+
static final String APP_LAST_UPDATE_TIME_FILENAME = "app-last-update-time.json";
19+
20+
private static final Charset UTF_8 = Charset.forName("UTF-8");
21+
22+
private final @NotNull SentryOptions options;
23+
private final long lastUpdateTime;
24+
25+
PersistingOptionsCacheGenerationObserver(
26+
final @NotNull SentryOptions options, final long lastUpdateTime) {
27+
this.options = options;
28+
this.lastUpdateTime = lastUpdateTime;
29+
}
30+
31+
@Override
32+
public void setRelease(final @Nullable String release) {
33+
final File cacheDir = new File(options.getCacheDirPath(), OPTIONS_CACHE);
34+
cacheDir.mkdirs();
35+
try (final OutputStream stream =
36+
new FileOutputStream(new File(cacheDir, APP_LAST_UPDATE_TIME_FILENAME))) {
37+
stream.write(Long.toString(lastUpdateTime).getBytes(UTF_8));
38+
} catch (Throwable e) {
39+
options
40+
.getLogger()
41+
.log(io.sentry.SentryLevel.ERROR, e, "Failed to persist options cache generation.");
42+
}
43+
}
44+
45+
static @Nullable Long read(final @NotNull SentryOptions options) {
46+
if (options.getCacheDirPath() == null) {
47+
return null;
48+
}
49+
try {
50+
final String value =
51+
FileUtils.readText(
52+
new File(
53+
new File(options.getCacheDirPath(), OPTIONS_CACHE),
54+
APP_LAST_UPDATE_TIME_FILENAME));
55+
return value == null ? null : Long.valueOf(value);
56+
} catch (Throwable e) {
57+
options
58+
.getLogger()
59+
.log(io.sentry.SentryLevel.ERROR, e, "Failed to read options cache generation.");
60+
return null;
61+
}
62+
}
63+
64+
@Override
65+
public void setProguardUuid(final @Nullable String proguardUuid) {}
66+
67+
@Override
68+
public void setSdkVersion(final @Nullable SdkVersion sdkVersion) {}
69+
70+
@Override
71+
public void setEnvironment(final @Nullable String environment) {}
72+
73+
@Override
74+
public void setDist(final @Nullable String dist) {}
75+
76+
@Override
77+
public void setTags(final @NotNull Map<String, @NotNull String> tags) {}
78+
79+
@Override
80+
public void setReplayErrorSampleRate(final @Nullable Double replayErrorSampleRate) {}
81+
}

sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -843,6 +843,21 @@ class AndroidOptionsInitializerTest {
843843
assertTrue { fixture.sentryOptions.optionsObservers.any { it is PersistingOptionsObserver } }
844844
}
845845

846+
@Test
847+
fun `options cache generation observer is set when app update time is valid`() {
848+
val buildInfo = mock<BuildInfoProvider>()
849+
whenever(buildInfo.sdkInfoVersion).thenReturn(Build.VERSION_CODES.LOLLIPOP)
850+
ContextUtils.getPackageInfo(fixture.context, buildInfo)!!.lastUpdateTime = 1_000L
851+
852+
fixture.initSut(useRealContext = true)
853+
854+
assertTrue {
855+
fixture.sentryOptions.optionsObservers.any {
856+
it is PersistingOptionsCacheGenerationObserver
857+
}
858+
}
859+
}
860+
846861
@Test
847862
fun `when cacheDir is not set, persisting observers are not set to options`() {
848863
fixture.initSut(configureOptions = { cacheDirPath = null })

sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoEventProcessorTest.kt

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -495,6 +495,46 @@ class ApplicationExitInfoEventProcessorTest {
495495
assertNull(app.appIdentifier)
496496
}
497497

498+
@Test
499+
fun `if options cache is from an older app update, uses current options`() {
500+
val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint(timestamp = 3_000))
501+
val processor = fixture.getSut(tmpDir)
502+
fixture.options.release = "io.sentry.samples@2.0.0+300"
503+
fixture.options.environment = "current-user"
504+
fixture.options.dist = "current-dist"
505+
fixture.persistOptions(RELEASE_FILENAME, "io.sentry.samples@1.0.0+100")
506+
fixture.persistOptions(ENVIRONMENT_FILENAME, "previous-user")
507+
fixture.persistOptions(DIST_FILENAME, "previous-dist")
508+
PersistingOptionsCacheGenerationObserver(fixture.options, 1_000L).setRelease(null)
509+
setLastUpdateTime(2_000)
510+
511+
val processed = processor.process(SentryEvent(), hint)!!
512+
513+
assertEquals("io.sentry.samples@2.0.0+300", processed.release)
514+
assertEquals("current-user", processed.environment)
515+
assertEquals("current-dist", processed.dist)
516+
}
517+
518+
@Test
519+
fun `if options cache is from current app update, uses persisted options`() {
520+
val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint(timestamp = 2_000))
521+
val processor = fixture.getSut(tmpDir)
522+
fixture.options.release = "io.sentry.samples@1.0.0+100"
523+
fixture.options.environment = "current-user"
524+
fixture.options.dist = "current-dist"
525+
fixture.persistOptions(RELEASE_FILENAME, "io.sentry.samples@1.0.0+100")
526+
fixture.persistOptions(ENVIRONMENT_FILENAME, "crashed-user")
527+
fixture.persistOptions(DIST_FILENAME, "crashed-dist")
528+
PersistingOptionsCacheGenerationObserver(fixture.options, 1_000L).setRelease(null)
529+
setLastUpdateTime(1_000)
530+
531+
val processed = processor.process(SentryEvent(), hint)!!
532+
533+
assertEquals("io.sentry.samples@1.0.0+100", processed.release)
534+
assertEquals("crashed-user", processed.environment)
535+
assertEquals("crashed-dist", processed.dist)
536+
}
537+
498538
@Test
499539
fun `historical event leaves release empty when app was updated`() {
500540
val hint =

0 commit comments

Comments
 (0)