Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/Packages/Audience/Runtime/ImmutableAudience.cs
Original file line number Diff line number Diff line change
Expand Up @@ -720,7 +720,7 @@ internal static void ResetState()
}
}

internal static ConsentLevel CurrentConsentForTesting => _state.Level;
internal static ConsentLevel CurrentConsent => _state.Level;

internal static void FlushQueueToDiskForTesting() => _queue?.FlushSync();

Expand Down
43 changes: 29 additions & 14 deletions src/Packages/Audience/Runtime/Transport/DiskStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using System.Threading;

namespace Immutable.Audience
{
Expand All @@ -14,10 +15,17 @@ internal sealed class DiskStore
{
private readonly string _queueDir;

// Cached queue file count: on-disk count at construction, plus
// tracked deltas from Write/Delete. Tests that plant files
// outside the DiskStore API will drift this and should assert
// on filesystem state, not Count().
private int _cachedCount;

internal DiskStore(string persistentDataPath)
{
_queueDir = Path.Combine(persistentDataPath, "imtbl_audience", "queue");
Directory.CreateDirectory(_queueDir);
_cachedCount = Directory.GetFiles(_queueDir, "*.json").Length;
}

// Atomically writes json as a new event file.
Expand All @@ -29,16 +37,19 @@ internal void Write(string json)

File.WriteAllText(tmpPath, json);

var replaced = false;
try
{
File.Move(tmpPath, finalPath);
}
catch (IOException)
{
// Destination already exists (unlikely but safe to handle)
File.Delete(finalPath);
File.Move(tmpPath, finalPath);
replaced = true;
}

if (!replaced) BumpCount(+1);
}

// Returns up to maxSize file paths, oldest first. Stale files
Expand Down Expand Up @@ -71,7 +82,7 @@ internal IReadOnlyList<string> ReadBatch(int maxSize)
var fileTime = new DateTime(ticks, DateTimeKind.Utc);
if (fileTime < cutoff)
{
TryDelete(path);
if (TryDelete(path)) BumpCount(-1);
continue;
}
}
Expand All @@ -86,17 +97,21 @@ internal IReadOnlyList<string> ReadBatch(int maxSize)
internal void Delete(IEnumerable<string> paths)
{
foreach (var path in paths)
TryDelete(path);
if (TryDelete(path)) BumpCount(-1);
}

// Total number of event files currently on disk.
internal int Count() => Directory.GetFiles(_queueDir, "*.json").Length;
// Total number of event files currently on disk. Reads the cached
// count seeded at construction; mutating ops maintain it.
internal int Count() => Volatile.Read(ref _cachedCount);

private void BumpCount(int delta) => Interlocked.Add(ref _cachedCount, delta);

private static void TryDelete(string path)
private static bool TryDelete(string path)
{
try { File.Delete(path); }
catch (IOException) { }
catch (UnauthorizedAccessException) { }
try { File.Delete(path); return true; }
catch (DirectoryNotFoundException) { return true; }
catch (IOException) { return false; }
catch (UnauthorizedAccessException) { return false; }
}
internal void DeleteAll()
{
Expand All @@ -105,7 +120,7 @@ internal void DeleteAll()
catch (DirectoryNotFoundException) { return; }

foreach (var path in paths)
TryDelete(path);
if (TryDelete(path)) BumpCount(-1);
}

// Drops queued identify/alias files, strips userId from track files.
Expand All @@ -126,13 +141,13 @@ private void ApplyAnonymousDowngradeToFile(string path)
!msg.TryGetValue(MessageFields.Type, out var typeObj) ||
!(typeObj is string type))
{
TryDelete(path);
if (TryDelete(path)) BumpCount(-1);
return;
}

if (IsIdentityMessage(type))
{
TryDelete(path);
if (TryDelete(path)) BumpCount(-1);
return;
}

Expand Down Expand Up @@ -176,11 +191,11 @@ private void RewriteTrackWithoutUserId(string path, Dictionary<string, object> m
catch (IOException)
{
// Delete rather than leave the old userId-bearing payload.
TryDelete(path);
if (TryDelete(path)) BumpCount(-1);
}
catch (UnauthorizedAccessException)
{
TryDelete(path);
if (TryDelete(path)) BumpCount(-1);
}
}

Expand Down
58 changes: 47 additions & 11 deletions src/Packages/Audience/Runtime/Utility/Json.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Globalization;
Expand All @@ -7,10 +8,14 @@ namespace Immutable.Audience
{
internal static class Json
{
// Depth cap so a pathological input throws FormatException
// instead of blowing the stack (StackOverflow is uncatchable).
internal const int MaxDepth = 64;

internal static string Serialize(Dictionary<string, object> data)
{
var sb = new StringBuilder();
WriteObject(sb, data, indent: 0, depth: 0);
WriteObject(sb, data, indent: 0, depth: 0, visited: null);
return sb.ToString();
}

Expand All @@ -22,11 +27,11 @@ internal static string Serialize(Dictionary<string, object> data, int indent)
{
if (indent <= 0) return Serialize(data);
var sb = new StringBuilder();
WriteObject(sb, data, indent, depth: 0);
WriteObject(sb, data, indent, depth: 0, visited: null);
return sb.ToString();
}

private static void WriteValue(StringBuilder sb, object value, int indent, int depth)
private static void WriteValue(StringBuilder sb, object value, int indent, int depth, HashSet<object> visited)
{
if (value == null)
{
Expand Down Expand Up @@ -68,22 +73,25 @@ private static void WriteValue(StringBuilder sb, object value, int indent, int d
}
else if (value is Dictionary<string, object> dict)
{
WriteObject(sb, dict, indent, depth);
WriteObject(sb, dict, indent, depth, visited);
}
else if (value is IList list)
{
WriteArray(sb, list, indent, depth);
WriteArray(sb, list, indent, depth, visited);
}
else
{
WriteString(sb, value.ToString());
}
}

private static void WriteObject(StringBuilder sb, Dictionary<string, object> dict, int indent, int depth)
private static void WriteObject(StringBuilder sb, Dictionary<string, object> dict, int indent, int depth, HashSet<object> visited)
{
GuardDepth(depth);
visited = EnterContainer(dict, visited);

sb.Append('{');
if (dict.Count == 0) { sb.Append('}'); return; }
if (dict.Count == 0) { sb.Append('}'); visited.Remove(dict); return; }

var pretty = indent > 0;
var first = true;
Expand All @@ -94,26 +102,31 @@ private static void WriteObject(StringBuilder sb, Dictionary<string, object> dic
if (pretty) AppendNewline(sb, indent, depth + 1);
WriteString(sb, kvp.Key);
sb.Append(pretty ? ": " : ":");
WriteValue(sb, kvp.Value, indent, depth + 1);
WriteValue(sb, kvp.Value, indent, depth + 1, visited);
}
if (pretty) AppendNewline(sb, indent, depth);
sb.Append('}');
visited.Remove(dict);
}

private static void WriteArray(StringBuilder sb, IList list, int indent, int depth)
private static void WriteArray(StringBuilder sb, IList list, int indent, int depth, HashSet<object> visited)
{
GuardDepth(depth);
visited = EnterContainer(list, visited);

sb.Append('[');
if (list.Count == 0) { sb.Append(']'); return; }
if (list.Count == 0) { sb.Append(']'); visited.Remove(list); return; }

var pretty = indent > 0;
for (var i = 0; i < list.Count; i++)
{
if (i > 0) sb.Append(',');
if (pretty) AppendNewline(sb, indent, depth + 1);
WriteValue(sb, list[i], indent, depth + 1);
WriteValue(sb, list[i], indent, depth + 1, visited);
}
if (pretty) AppendNewline(sb, indent, depth);
sb.Append(']');
visited.Remove(list);
}

private static void AppendNewline(StringBuilder sb, int indent, int depth)
Expand All @@ -122,6 +135,29 @@ private static void AppendNewline(StringBuilder sb, int indent, int depth)
sb.Append(' ', indent * depth);
}

private static void GuardDepth(int depth)
{
if (depth >= MaxDepth)
throw new FormatException(
$"JSON nesting exceeds {MaxDepth} levels — refusing to serialize. " +
"Check for a cyclic or excessively deep dictionary/list.");
}

private static HashSet<object> EnterContainer(object container, HashSet<object> visited)
{
visited ??= new HashSet<object>(ReferenceEqualityComparer.Instance);
if (!visited.Add(container))
throw new FormatException("JSON graph contains a cycle — refusing to serialize.");
return visited;
}

private sealed class ReferenceEqualityComparer : IEqualityComparer<object>
{
internal static readonly ReferenceEqualityComparer Instance = new ReferenceEqualityComparer();
public new bool Equals(object x, object y) => ReferenceEquals(x, y);
public int GetHashCode(object obj) => System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(obj);
}

private static void WriteString(StringBuilder sb, string s)
{
sb.Append('"');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -677,7 +677,7 @@ public void Init_ConcurrentWithSetConsent_LeavesConsistentState()
// early-returns, Init then initialises with Anonymous.
// - Init runs first: Init sets Anonymous, SetConsent flips
// to None under the lock, consent ends at None.
var finalConsent = ImmutableAudience.CurrentConsentForTesting;
var finalConsent = ImmutableAudience.CurrentConsent;
Assert.That(finalConsent,
Is.EqualTo(ConsentLevel.None).Or.EqualTo(ConsentLevel.Anonymous),
$"iteration {iter}: unexpected final consent {finalConsent}");
Expand Down Expand Up @@ -879,7 +879,7 @@ public void SetConsent_PersistsAcrossInit()
// Re-init with the *original* (Anonymous) config — persisted Full should win.
ImmutableAudience.Init(MakeConfig(ConsentLevel.Anonymous));

Assert.AreEqual(ConsentLevel.Full, ImmutableAudience.CurrentConsentForTesting,
Assert.AreEqual(ConsentLevel.Full, ImmutableAudience.CurrentConsent,
"persisted consent must override the config default after restart");
}

Expand Down
44 changes: 44 additions & 0 deletions src/Packages/Audience/Tests/Runtime/Utility/JsonTests.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System;
using System.Collections.Generic;
using NUnit.Framework;

Expand Down Expand Up @@ -203,5 +204,48 @@ public void Serialize_RealisticEventPayload_ProducesCorrectJson()
StringAssert.Contains("\"perfect\":true", result);
StringAssert.Contains("\"tags\":[\"fast\",\"clean\"]", result);
}

[Test]
public void Serialize_NestingExceedsMaxDepth_ThrowsFormatException()
{
var root = new Dictionary<string, object>();
var current = root;
for (var i = 0; i < Json.MaxDepth; i++)
{
var next = new Dictionary<string, object>();
current["next"] = next;
current = next;
}

var ex = Assert.Throws<FormatException>(() => Json.Serialize(root));
StringAssert.Contains("nesting exceeds", ex.Message);
}

[Test]
public void Serialize_SelfReferentialDict_ThrowsFormatException()
{
var root = new Dictionary<string, object>();
root["self"] = root;

var ex = Assert.Throws<FormatException>(() => Json.Serialize(root));
StringAssert.Contains("cycle", ex.Message);
}

[Test]
public void Serialize_SharedChildInSiblingKeys_IsNotTreatedAsCycle()
{
// Diamond: visited set tracks the current recursion stack, not all objects ever seen.
var shared = new Dictionary<string, object> { ["k"] = "v" };
var root = new Dictionary<string, object>
{
["a"] = shared,
["b"] = shared,
};

var result = Json.Serialize(root);

StringAssert.Contains("\"a\":{\"k\":\"v\"}", result);
StringAssert.Contains("\"b\":{\"k\":\"v\"}", result);
}
}
}
Loading