Files
Cameron Cordes 615941a511 fix(dates): assumed_server fallback uses the offset at the photo's wall, not now's
A container whose /etc/localtime is a copy rather than a symlink makes
server_zone() return None; the assumed_server rung then fell back to
Local::now()'s offset, which is only correct for captures from the
current season. Historical winter walls on a DST host skewed date_utc
by an hour. Resolve the process-local zone at the wall instead
(earliest reading for ambiguous walls).

Surfaced by test-server validation of #119.
2026-09-26 23:38:50 -04:00

526 lines
20 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Camera-offset cascade and display-zone helpers for the true-instant date
//! contract (see `docs/adr/0001-true-utc-dates.md`).
//!
//! The legacy `image_exif.date_taken` column mixes conventions: EXIF-sourced
//! rows hold camera wall-clock *as UTC*, exiftool / filesystem rows hold true
//! UTC instants. The new columns — `date_utc`, `camera_offset_minutes`,
//! `date_taken_tz_source` — are populated at ingest by the offset cascade:
//!
//! 1. **offset tag** — the EXIF `OffsetTime*` tag, when the camera stamped
//! it (explicit, most accurate).
//! 2. **gps** — the capture location resolved to an IANA zone (bundled
//! offline TZF geometry) and that zone's offset for the capture *date*
//! (DST-correct via chrono-tz).
//! 3. **assumed_server** — the server's own IANA zone, offset at the
//! capture date. A camera without offset or GPS was probably carried
//! around by its owner, i.e. in the operator's home zone.
//!
//! The cascade always returns `(offset_minutes, source)` so the caller can
//! compute `date_utc = wall − offset` and audit why the row has the offset
//! it does. Branches that already produce true UTC instants (exiftool,
//! filesystem time) skip the cascade entirely and are recorded as
//! [`TzSource::UtcDirect`].
use std::str::FromStr;
use std::sync::OnceLock;
use chrono::{Local, LocalResult, NaiveDateTime, Offset, TimeZone};
use chrono_tz::Tz;
/// Why a `date_utc` row has the offset it does — or that it is a direct
/// true instant. Written verbatim to `image_exif.date_taken_tz_source`.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum TzSource {
/// An EXIF `OffsetTime*` tag supplied the camera offset directly.
OffsetTag,
/// GPS → IANA-zone lookup resolved the offset for the capture date.
Gps,
/// No offset tag and no usable GPS: assumed the server's zone.
AssumedServer,
/// The resolver branch already produced a true UTC instant (exiftool,
/// filesystem time, unix-timestamp filename). No offset is involved.
UtcDirect,
/// An operator set the date manually (POST /image/exif/date, ticket
/// #115): the wall the user picked, interpreted in their display zone.
Manual,
}
impl TzSource {
pub fn as_str(self) -> &'static str {
match self {
TzSource::OffsetTag => "offset_tag",
TzSource::Gps => "gps",
TzSource::AssumedServer => "assumed_server",
TzSource::UtcDirect => "utc_direct",
TzSource::Manual => "manual",
}
}
}
/// Evidence available to the offset cascade for one file.
#[derive(Copy, Clone, Debug, Default)]
pub struct OffsetEvidence {
/// Signed minutes from the EXIF `OffsetTime*` tag (e.g. EDT = `-240`),
/// when the camera stamped one.
pub offset_tag_minutes: Option<i32>,
/// `(latitude, longitude)` from the EXIF GPS tags, when present.
pub gps: Option<(f64, f64)>,
}
/// A resolved camera offset for one capture.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub struct OffsetResolution {
/// Camera local time minus UTC, in minutes (negative = west of UTC).
pub minutes: i32,
/// Which rung of the cascade produced it.
pub source: TzSource,
}
/// GPS → IANA-zone seam. The production adapter wraps the bundled offline
/// TZF geometry; tests inject a stub.
pub trait GpsZoneLookup {
fn zone_at(&self, lat: f64, lon: f64) -> Option<Tz>;
}
/// Production [`GpsZoneLookup`]: bundled lite TZF geometry. No network,
/// ~4 MB, loaded once per process on first use.
pub struct TzfGpsZoneLookup {
finder: OnceLock<tzf_rs::DefaultFinder>,
}
impl TzfGpsZoneLookup {
pub fn new() -> Self {
Self {
finder: OnceLock::new(),
}
}
fn finder(&self) -> &tzf_rs::DefaultFinder {
self.finder.get_or_init(tzf_rs::DefaultFinder::new)
}
}
impl Default for TzfGpsZoneLookup {
fn default() -> Self {
Self::new()
}
}
impl GpsZoneLookup for TzfGpsZoneLookup {
fn zone_at(&self, lat: f64, lon: f64) -> Option<Tz> {
// tzf-rs takes (lng, lat) — the reverse of our (lat, lon) convention.
let name = self.finder().get_tz_name(lon, lat);
if name.is_empty() {
return None;
}
Tz::from_str(name).ok()
}
}
/// The zone offset, in minutes, for a naive wall-clock reading.
///
/// DST-correct: resolves the zone's offset for the wall-clock *date itself*
/// (the same zone rule the camera's own clock followed). Ambiguous
/// fall-back wall times resolve to the earlier (pre-transition) offset;
/// nonexistent spring-forward wall times to the later one — each within one
/// hour of the true capture instant, never off by a full day.
pub fn offset_minutes_for_zone(zone: Tz, wall: NaiveDateTime) -> i32 {
let dt = match zone.from_local_datetime(&wall) {
LocalResult::Single(dt) => dt,
LocalResult::Ambiguous(early, _) => early,
// Nonexistent spring-forward wall time: use the zone's offset at
// that wall reading interpreted as UTC — within an hour of truth.
LocalResult::None => zone.from_utc_datetime(&wall),
};
dt.offset().fix().local_minus_utc() / 60
}
/// True UTC instant (unix seconds) for a camera wall-clock reading and its
/// resolved offset: `instant = wall − offset`.
///
/// e.g. wall `2024-07-04 12:00:00` at EDT (`-240`) → `16:00:00Z`.
pub fn wall_to_instant(wall: NaiveDateTime, offset_minutes: i32) -> i64 {
wall.and_utc().timestamp() - (offset_minutes as i64) * 60
}
/// The offset cascade: offset tag → GPS zone → server zone.
///
/// `server_zone` is the operator's home zone (see [`server_zone`]); when it
/// is `None` (unresolvable on this host) the cascade falls back to the
/// process-local offset, which is only exact for fixed-offset zones.
pub fn resolve_camera_offset(
wall: NaiveDateTime,
evidence: &OffsetEvidence,
server_zone: Option<Tz>,
zone_lookup: &dyn GpsZoneLookup,
) -> OffsetResolution {
if let Some(minutes) = evidence.offset_tag_minutes {
return OffsetResolution {
minutes,
source: TzSource::OffsetTag,
};
}
if let Some((lat, lon)) = evidence.gps
&& let Some(zone) = zone_lookup.zone_at(lat, lon)
{
return OffsetResolution {
minutes: offset_minutes_for_zone(zone, wall),
source: TzSource::Gps,
};
}
let minutes = match server_zone {
Some(zone) => offset_minutes_for_zone(zone, wall),
// Zone name unresolvable on this host: assume the process-local
// zone, but at the PHOTO's wall (a DST host's current-season
// offset would skew historical captures by an hour). Ambiguous
// walls take the earliest (pre-transition) reading; truly
// nonexistent local times fall back to the current offset.
None => match Local.from_local_datetime(&wall) {
LocalResult::Single(dt) => dt.offset().fix().local_minus_utc() / 60,
LocalResult::Ambiguous(dt, _) => dt.offset().fix().local_minus_utc() / 60,
LocalResult::None => Local::now().offset().fix().local_minus_utc() / 60,
},
};
OffsetResolution {
minutes,
source: TzSource::AssumedServer,
}
}
/// Resolve the server's IANA zone from the host environment, in order:
/// `TZ` env var (zone-name form), `/etc/timezone`, then the
/// `/etc/localtime` symlink target. Returns `None` when nothing parses —
/// callers then fall back to the process-local offset.
pub fn server_zone() -> Option<Tz> {
if let Ok(tz) = std::env::var("TZ")
&& let Some(zone) = Tz::from_str(tz.trim()).ok()
{
return Some(zone);
}
if let Ok(contents) = std::fs::read_to_string("/etc/timezone")
&& let Some(zone) = Tz::from_str(contents.trim()).ok()
{
return Some(zone);
}
if let Ok(target) = std::fs::read_link("/etc/localtime") {
let target = target.to_string_lossy().into_owned();
let zone_name = target
.trim_start_matches("/usr/share/zoneinfo/")
.trim_start_matches("/etc/zoneinfo/")
.trim_start_matches("/var/db/timezone/zoneinfo/");
if !zone_name.is_empty()
&& zone_name != "localtime"
&& let Ok(zone) = Tz::from_str(zone_name)
{
return Some(zone);
}
}
None
}
/// App default display zone when the user hasn't stored one
/// (`user_ai_prefs.display_tz` NULL).
pub const DEFAULT_DISPLAY_TZ: &str = "America/New_York";
/// Resolve the user's stored display zone to a usable IANA name.
/// NULL / empty / unparseable prefs fall back to the app default, so
/// read paths (tickets #119/#120) never have to special-case the
/// unset state themselves.
pub fn display_zone_for(stored: Option<&str>) -> String {
match stored.map(str::trim).filter(|s| !s.is_empty()) {
Some(name) if Tz::from_str(name).is_ok() => name.to_string(),
_ => DEFAULT_DISPLAY_TZ.to_string(),
}
}
/// Pure seam for the manual date-edit endpoints (ADR-0001, ticket #115):
/// reinterpret the naive-as-UTC epoch the UI sent as a wall clock in the
/// user's display zone, and produce the new-contract
/// `(date_utc, camera_offset_minutes)` pair. The caller writes
/// `date_taken_tz_source = 'manual'` itself and stores the epoch verbatim
/// in the legacy `date_taken` column (byte-identical to the pre-rework
/// behavior).
///
/// `stored_zone` is `user_ai_prefs.display_tz` (NULL / unparseable → app
/// default). The offset follows the wall's own date through the zone's DST
/// rules — ambiguous fall-back walls take the earlier (pre-transition)
/// offset; nonexistent spring-forward walls take the zone's offset at the
/// wall-as-UTC reading, within an hour of truth (see
/// [`offset_minutes_for_zone`]). `None` only when the zone name is
/// unparseable, which [`display_zone_for`] already rules out — the caller
/// then leaves the new-contract triple untouched.
pub fn manual_date_resolution(
stored_zone: Option<&str>,
wall_naive_epoch: i64,
) -> Option<(i64, i32)> {
let zone = Tz::from_str(&display_zone_for(stored_zone)).ok()?;
let wall = chrono::DateTime::from_timestamp(wall_naive_epoch, 0).map(|dt| dt.naive_utc())?;
let minutes = offset_minutes_for_zone(zone, wall);
Some((wall_to_instant(wall, minutes), minutes))
}
/// Display-zone offset, in minutes, for a true UTC instant — DST-aware.
/// `None` when the zone name doesn't parse.
#[allow(dead_code)] // consumed by the read-side cutover (#119/#120)
pub fn display_zone_offset_minutes(zone_name: &str, instant: i64) -> Option<i32> {
let zone = Tz::from_str(zone_name).ok()?;
let dt = zone.timestamp_opt(instant, 0).single()?;
Some(dt.offset().fix().local_minus_utc() / 60)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn display_zone_for_falls_back_to_default() {
assert_eq!(display_zone_for(None).as_str(), DEFAULT_DISPLAY_TZ);
assert_eq!(display_zone_for(Some("")).as_str(), DEFAULT_DISPLAY_TZ);
assert_eq!(display_zone_for(Some(" ")).as_str(), DEFAULT_DISPLAY_TZ);
assert_eq!(
display_zone_for(Some("Not/A_Zone")).as_str(),
DEFAULT_DISPLAY_TZ
);
}
#[test]
fn display_zone_for_passes_through_valid_names() {
assert_eq!(display_zone_for(Some("Europe/Berlin")), "Europe/Berlin");
assert_eq!(display_zone_for(Some(" Asia/Tokyo ")), "Asia/Tokyo");
}
/// Stub lookup for cascade tests: one fixed zone or none.
struct StubLookup(Option<Tz>);
impl GpsZoneLookup for StubLookup {
fn zone_at(&self, _lat: f64, _lon: f64) -> Option<Tz> {
self.0
}
}
fn ny() -> Tz {
chrono_tz::America::New_York
}
fn wall_july() -> NaiveDateTime {
NaiveDateTime::parse_from_str("2024-07-04 12:00:00", "%Y-%m-%d %H:%M:%S").unwrap()
}
fn wall_january() -> NaiveDateTime {
NaiveDateTime::parse_from_str("2024-01-15 12:00:00", "%Y-%m-%d %H:%M:%S").unwrap()
}
#[test]
fn offset_tag_wins_over_everything() {
let evidence = OffsetEvidence {
offset_tag_minutes: Some(330), // +05:30
gps: Some((40.71, -74.00)),
};
let res =
resolve_camera_offset(wall_july(), &evidence, Some(ny()), &StubLookup(Some(ny())));
assert_eq!(res.minutes, 330);
assert_eq!(res.source, TzSource::OffsetTag);
}
#[test]
fn gps_zone_wins_when_no_tag() {
let evidence = OffsetEvidence {
offset_tag_minutes: None,
gps: Some((40.71, -74.00)),
};
let res =
resolve_camera_offset(wall_july(), &evidence, Some(ny()), &StubLookup(Some(ny())));
assert_eq!(res.minutes, -240, "July wall time in NY is EDT (UTC-4)");
assert_eq!(res.source, TzSource::Gps);
}
#[test]
fn server_zone_is_last_resort() {
let evidence = OffsetEvidence::default();
let res = resolve_camera_offset(
wall_january(),
&evidence,
Some(ny()),
&StubLookup(Some(ny())), // stub is ignored: no GPS evidence
);
assert_eq!(res.minutes, -300, "January wall time in NY is EST (UTC-5)");
assert_eq!(res.source, TzSource::AssumedServer);
}
/// The offset the process-local zone assigns to a wall clock —
/// independent of the current season.
fn local_offset_at(wall: NaiveDateTime) -> i32 {
Local
.from_local_datetime(&wall)
.earliest()
.map(|dt| dt.offset().fix().local_minus_utc() / 60)
.unwrap_or_else(|| Local::now().offset().fix().local_minus_utc() / 60)
}
#[test]
fn assumed_server_fallback_is_dst_aware_at_the_wall_when_zone_unresolvable() {
// `server_zone() = None` (e.g. a container whose /etc/localtime is a
// copy, not a symlink): the fallback assumes the process-local zone
// AT THE PHOTO'S WALL, not the current season's offset. On a DST
// host this test catches the old behavior whenever the host is in
// either season (one of the two walls disagrees with `now`);
// on a fixed-offset host both assertions hold trivially.
let evidence = OffsetEvidence::default();
let winter = resolve_camera_offset(wall_january(), &evidence, None, &StubLookup(None));
let summer = resolve_camera_offset(wall_july(), &evidence, None, &StubLookup(None));
assert_eq!(
winter.minutes,
local_offset_at(wall_january()),
"winter wall must carry the winter offset"
);
assert_eq!(
summer.minutes,
local_offset_at(wall_july()),
"summer wall must carry the summer offset"
);
assert_eq!(winter.source, TzSource::AssumedServer);
}
#[test]
fn gps_miss_falls_through_to_server_zone() {
let evidence = OffsetEvidence {
offset_tag_minutes: None,
gps: Some((30.0, -40.0)), // open ocean: lookup returns None
};
let res = resolve_camera_offset(wall_january(), &evidence, Some(ny()), &StubLookup(None));
assert_eq!(res.source, TzSource::AssumedServer);
}
#[test]
fn zone_offset_is_dst_correct_for_capture_date() {
assert_eq!(offset_minutes_for_zone(ny(), wall_july()), -240);
assert_eq!(offset_minutes_for_zone(ny(), wall_january()), -300);
// Berlin: CEST +120 in summer, CET +60 in winter.
let berlin = chrono_tz::Europe::Berlin;
assert_eq!(offset_minutes_for_zone(berlin, wall_july()), 120);
assert_eq!(offset_minutes_for_zone(berlin, wall_january()), 60);
}
#[test]
fn wall_to_instant_edt_example() {
// Wall 2024-07-04 12:00:00 at EDT (-240) is 16:00:00 UTC
// = 1720108800. This is the exact pair the mixed-convention audit
// used to prove the legacy EXIF branch was off by 4 hours.
assert_eq!(wall_to_instant(wall_july(), -240), 1_720_108_800);
}
#[test]
fn wall_to_instant_positive_offset() {
// Wall 2024-07-04 12:00:00 at +05:30 (330) is 06:30:00 UTC.
let expected = wall_july().and_utc().timestamp() - 330 * 60;
assert_eq!(wall_to_instant(wall_july(), 330), expected);
}
#[test]
fn display_zone_offset_minutes_is_dst_aware() {
// 2024-07-04 16:00:00Z — New York is on EDT.
assert_eq!(
display_zone_offset_minutes("America/New_York", 1_720_108_800),
Some(-240)
);
// 2024-01-15 17:00:00Z — New York is on EST.
assert_eq!(
display_zone_offset_minutes("America/New_York", 1_705_335_600),
Some(-300)
);
}
#[test]
fn manual_date_resolution_display_zone_edt() {
// The UI sends the wall rendered in the display zone, as a
// naive-as-UTC epoch: 1720080000 = "2024-07-04 04:00:00". July in
// New York is EDT (-240): the instant is 08:00:00Z.
let (utc, off) = manual_date_resolution(Some("America/New_York"), 1_720_080_000).unwrap();
assert_eq!(off, -240);
assert_eq!(utc, 1_720_080_000 + 4 * 3600);
}
#[test]
fn manual_date_resolution_display_zone_est() {
// Same function in January: EST (-300). Wall 2024-01-15 04:00:00
// (naive epoch 1705291200) → 09:00:00Z.
let (utc, off) = manual_date_resolution(Some("America/New_York"), 1_705_291_200).unwrap();
assert_eq!(off, -300);
assert_eq!(utc, 1_705_291_200 + 5 * 3600);
}
#[test]
fn manual_date_resolution_positive_zone_and_default() {
// Tokyo is +09:00 year-round: wall 04:00 → previous day 19:00Z.
let (utc, off) = manual_date_resolution(Some("Asia/Tokyo"), 1_720_080_000).unwrap();
assert_eq!(off, 540);
assert_eq!(utc, 1_720_080_000 - 540 * 60);
// NULL stored zone → app default (America/New_York), July → EDT.
let (_, off) = manual_date_resolution(None, 1_720_080_000).unwrap();
assert_eq!(off, -240);
// Unparseable stored zone also falls back to the default.
let (_, off) = manual_date_resolution(Some("Not/A_Zone"), 1_720_080_000).unwrap();
assert_eq!(off, -240);
}
#[test]
fn display_zone_offset_minutes_unknown_zone_is_none() {
assert_eq!(
display_zone_offset_minutes("Not/A_Zone", 1_700_000_000),
None
);
}
#[test]
fn tz_source_as_str_round_trip() {
let pairs = [
(TzSource::OffsetTag, "offset_tag"),
(TzSource::Gps, "gps"),
(TzSource::AssumedServer, "assumed_server"),
(TzSource::UtcDirect, "utc_direct"),
(TzSource::Manual, "manual"),
];
for (src, expected) in pairs {
assert_eq!(src.as_str(), expected);
}
}
#[test]
fn tzf_lookup_resolves_new_york() {
let lookup = TzfGpsZoneLookup::new();
assert_eq!(
lookup.zone_at(40.7128, -74.0060),
Some(chrono_tz::America::New_York)
);
}
#[test]
fn tzf_lookup_resolves_berlin() {
let lookup = TzfGpsZoneLookup::new();
assert_eq!(
lookup.zone_at(52.5200, 13.4050),
Some(chrono_tz::Europe::Berlin)
);
}
#[test]
fn tzf_lookup_open_ocean_resolves_etc_zone() {
// The bundled lite data covers open ocean with fixed-offset
// Etc/GMT* zones — a mid-Atlantic photo gets a sane local offset
// rather than falling through to the server zone.
let lookup = TzfGpsZoneLookup::new();
let zone = lookup
.zone_at(30.0, -40.0)
.expect("ocean resolves to Etc/GMT*");
assert!(zone.name().starts_with("Etc/GMT"), "got {}", zone.name());
}
#[test]
fn server_zone_resolves_on_this_host_or_none() {
// The host's zone is whatever it is — the contract is that a
// parseable result is a real Tz and a missing config is None, not a
// panic. Both outcomes are valid; assert only the no-panic shape.
let _ = server_zone();
}
}