package com.chaegeon.niro; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; /** Root-only, per-run E2E evidence. No request, location or secret is stored. */ final class WeatherRefreshCounters { private final File directory; private long signal; private long callback; WeatherRefreshCounters(File directory) throws IOException { if (!directory.isDirectory() || !directory.getCanonicalPath().equals(directory.getAbsolutePath())) { throw new IOException("invalid counter directory"); } this.directory = directory; writeAtomic("signal.counter", 0L); writeAtomic("callback.counter", 0L); } synchronized void signalAccepted() throws IOException { if (signal == Long.MAX_VALUE) { throw new IOException("signal counter overflow"); } writeAtomic("signal.counter", signal + 1L); ++signal; } synchronized void freshCallbackCompleted() throws IOException { if (callback == Long.MAX_VALUE) { throw new IOException("callback counter overflow"); } writeAtomic("callback.counter", callback + 1L); ++callback; } private void writeAtomic(String name, long value) throws IOException { File target = new File(directory, name); File pending = new File(directory, name + ".new"); if (pending.exists() || !pending.getCanonicalPath().equals(pending.getAbsolutePath()) || !target.getCanonicalPath().equals(target.getAbsolutePath())) { throw new IOException("counter path not safe"); } FileOutputStream output = new FileOutputStream(pending); try { if (!pending.setReadable(false, false) || !pending.setWritable(false, false) || !pending.setExecutable(false, false) || !pending.setReadable(true, true) || !pending.setWritable(true, true)) { throw new IOException("counter permission failure"); } output.write((Long.toString(value) + "\n").getBytes("US-ASCII")); output.getFD().sync(); } finally { output.close(); } if (!pending.renameTo(target)) { throw new IOException("counter replacement failed"); } } }