Skip to main content

monty_types/
os_policy.rs

1//! Session policies for clocks, sleeps and initial random state.
2
3use std::{error::Error, fmt, time::Duration};
4
5use chrono::{DateTime, Datelike, NaiveDate, NaiveDateTime, NaiveTime, TimeDelta, Timelike, Utc};
6use jiff::{
7    Timestamp, civil,
8    tz::{AmbiguousOffset, TimeZone},
9};
10use num_bigint::BigInt;
11
12use crate::object::MontyTimeZone;
13
14/// Policies for clocks, sleeps and initial random state on every execution path.
15/// `CallHost` suspends to the host, or raises `NotImplementedError` without one;
16/// the zone is always resolved in the sandbox.
17/// Defaults use the system clock, the UTC zone, OS entropy, a zero process clock,
18/// and sleeps capped at ten seconds. Hosts perform those sleeps without their
19/// `os` handler; standard execution waits inline.
20#[derive(Debug, Clone, PartialEq, Default, serde::Serialize, serde::Deserialize)]
21pub struct OsPolicy {
22    /// The instant `date.today()`, `datetime.now()` and `time.time()` read.
23    pub datetime: DateTimeSource,
24    /// The local zone: naive `datetime.now()` and `date.today()` read it,
25    /// `astimezone()`, `time.timezone`/`tzname` and `%Z` report it.
26    pub timezone: SandboxTimeZone,
27    /// What `time.sleep()` and `asyncio.sleep()` do.
28    pub sleep: SleepMode,
29    /// What `time.process_time()` and `time.thread_time()` report.
30    pub process_time: ProcessTime,
31    /// Where an unseeded `random` generator gets its first state.
32    pub random_start: RandomStart,
33}
34
35/// Where the clock calls read the instant.
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
37pub enum DateTimeSource {
38    /// The process's own clock.
39    #[default]
40    System,
41    /// Suspend to the host, which answers each call.
42    CallHost,
43    /// Every call reads this instant. Years outside 1..=9999 raise `OverflowError`.
44    Fixed {
45        /// Seconds since the Unix epoch, UTC.
46        unix_seconds: i64,
47        /// Sub-second component, 0..=999_999. Anything larger raises.
48        microsecond: u32,
49    },
50}
51
52impl DateTimeSource {
53    /// Reads the instant in UTC. Returns `None` for [`CallHost`](Self::CallHost)
54    /// or a [`Fixed`](Self::Fixed) instant Python's `datetime` cannot represent.
55    #[must_use]
56    pub fn read(self) -> Option<NaiveDateTime> {
57        let utc = match self {
58            Self::System => Utc::now().naive_utc(),
59            Self::CallHost => return None,
60            Self::Fixed {
61                unix_seconds,
62                microsecond,
63            } => {
64                // Chrono accepts leap seconds; Python requires microseconds below 1_000_000.
65                let nanoseconds = microsecond.checked_mul(1_000).filter(|ns| *ns < 1_000_000_000)?;
66                DateTime::from_timestamp(unix_seconds, nanoseconds)?.naive_utc()
67            }
68        };
69        // Apply datetime's year range to time.time() too.
70        local_wall_clock(utc, 0).map(|_| utc)
71    }
72}
73
74/// The sandbox's local zone, UTC unless configured.
75#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
76pub enum SandboxTimeZone {
77    /// A fixed offset from UTC, with the name `datetime.timezone(offset, name)`
78    /// would carry. No DST: every instant has the same offset and name.
79    Fixed {
80        /// Offset from UTC, in seconds.
81        offset_seconds: i32,
82        /// The zone's name, if it has one.
83        name: Option<String>,
84    },
85    /// An IANA zone with its transition rules, built only by [`named`](Self::named).
86    /// Serialises as its name, so each side of the wire resolves it against its
87    /// own database.
88    Named(NamedZone),
89}
90
91impl Default for SandboxTimeZone {
92    fn default() -> Self {
93        Self::utc()
94    }
95}
96
97impl SandboxTimeZone {
98    /// UTC named `UTC`, as CPython reports the zone under `TZ=UTC`.
99    #[must_use]
100    pub fn utc() -> Self {
101        Self::Fixed {
102            offset_seconds: 0,
103            name: Some("UTC".to_owned()),
104        }
105    }
106
107    /// Resolves an IANA zone name such as `Europe/London` against the tz database
108    /// the `tzdb` or `tzdb-bundled` feature provides. Without either, or for a
109    /// name the database lacks, returns [`UnknownTimeZone`].
110    pub fn named(name: &str) -> Result<Self, UnknownTimeZone> {
111        NamedZone::resolve(name).map(Self::Named)
112    }
113
114    /// The IANA name of a [`Named`](Self::Named) zone; `None` for a fixed offset.
115    #[must_use]
116    pub fn iana_name(&self) -> Option<&str> {
117        match self {
118            Self::Fixed { .. } => None,
119            Self::Named(zone) => Some(zone.name()),
120        }
121    }
122
123    /// The offset and name in force at the UTC instant `utc`: the `timezone`
124    /// that `astimezone()` attaches and `%Z` prints. A fixed zone's name is
125    /// `None` when it was not given one.
126    #[must_use]
127    pub fn at(&self, utc: NaiveDateTime) -> MontyTimeZone {
128        match self {
129            Self::Fixed { offset_seconds, name } => MontyTimeZone {
130                offset_seconds: *offset_seconds,
131                name: name.clone(),
132            },
133            Self::Named(zone) => {
134                let info = zone.zone.to_offset_info(timestamp(utc));
135                MontyTimeZone {
136                    offset_seconds: info.offset().seconds(),
137                    name: Some(info.abbreviation().to_owned()),
138                }
139            }
140        }
141    }
142
143    /// The offset in force for a naive wall-clock time in this zone. An ambiguous
144    /// time (a DST fold) takes its first occurrence and a skipped time (a gap) the
145    /// offset from before it, CPython's `fold=0` reading. `None` only outside the
146    /// civil year range, which is wider than `datetime`'s.
147    #[must_use]
148    pub fn offset_for_local(&self, local: NaiveDateTime) -> Option<i32> {
149        match self {
150            Self::Fixed { offset_seconds, .. } => Some(*offset_seconds),
151            // The offset rather than the instant: jiff's timestamps stop short of the
152            // last day a `datetime` holds, and `timestamp()` reaches past it anyway.
153            Self::Named(zone) => match zone.zone.to_ambiguous_timestamp(civil_datetime(local)?).offset() {
154                AmbiguousOffset::Unambiguous { offset }
155                | AmbiguousOffset::Gap { before: offset, .. }
156                | AmbiguousOffset::Fold { before: offset, .. } => Some(offset.seconds()),
157            },
158        }
159    }
160
161    /// The UTC instant a naive wall-clock time in this zone denotes, or `None`
162    /// outside years 1..=9999.
163    #[must_use]
164    pub fn utc_from_local(&self, local: NaiveDateTime) -> Option<NaiveDateTime> {
165        local_wall_clock(local, self.offset_for_local(local)?.checked_neg()?)
166    }
167
168    /// The `time` module's `timezone`, `altzone`, `daylight` and `tzname`, read
169    /// as CPython does from the zone's state on 1 January and 1 July of `year`.
170    /// A fixed zone ignores `year`; a named one needs it, so `None` gives `None`.
171    #[must_use]
172    pub fn constants(&self, year: Option<i32>) -> Option<ZoneConstants> {
173        let (january, july) = match self {
174            Self::Fixed { .. } => {
175                let zone = self.at(DateTime::UNIX_EPOCH.naive_utc());
176                (zone.clone(), zone)
177            }
178            Self::Named(_) => {
179                let year = year?;
180                let at = |month| Some(self.at(NaiveDate::from_ymd_opt(year, month, 1)?.and_time(NaiveTime::MIN)));
181                (at(1)?, at(7)?)
182            }
183        };
184        // `time.timezone` is seconds *west* of UTC; standard time is the one
185        // further west, which swaps the halves in the southern hemisphere.
186        let (standard, daylight) = if january.offset_seconds < july.offset_seconds {
187            (january, july)
188        } else {
189            (july, january)
190        };
191        Some(ZoneConstants {
192            daylight: standard.offset_seconds != daylight.offset_seconds,
193            standard,
194            daylight_zone: daylight,
195        })
196    }
197}
198
199/// A zone resolved from the tz database by [`SandboxTimeZone::named`], which is
200/// the only way to build one: every value carries the database's name for it.
201#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
202#[serde(try_from = "String", into = "String")]
203pub struct NamedZone {
204    name: String,
205    zone: TimeZone,
206}
207
208impl NamedZone {
209    /// The zone's IANA name as the database spells it.
210    #[must_use]
211    pub fn name(&self) -> &str {
212        &self.name
213    }
214
215    fn resolve(name: &str) -> Result<Self, UnknownTimeZone> {
216        // The chars IANA keys use, with no `.`/`..` components, so nothing
217        // traversal-shaped reaches the database's path lookup.
218        let valid_part = |part: &str| {
219            !matches!(part, "" | "." | "..") && part.bytes().all(|b| b.is_ascii_alphanumeric() || b"._+-".contains(&b))
220        };
221        let unknown = || UnknownTimeZone(name.to_owned());
222        if name.len() > 100 || !name.split('/').all(valid_part) {
223            return Err(unknown());
224        }
225        let zone = TimeZone::get(name).map_err(|_| unknown())?;
226        // `Etc/Unknown` resolves to jiff's nameless placeholder zone.
227        let name = zone.iana_name().ok_or_else(unknown)?.to_owned();
228        Ok(Self { name, zone })
229    }
230}
231
232impl TryFrom<String> for NamedZone {
233    type Error = UnknownTimeZone;
234
235    fn try_from(name: String) -> Result<Self, Self::Error> {
236        Self::resolve(&name)
237    }
238}
239
240impl From<NamedZone> for String {
241    fn from(zone: NamedZone) -> Self {
242        zone.name
243    }
244}
245
246/// A zone name [`SandboxTimeZone::named`] could not resolve: malformed, or
247/// absent from the tz database available to this build.
248#[derive(Debug, Clone, PartialEq, Eq)]
249pub struct UnknownTimeZone(pub String);
250
251impl fmt::Display for UnknownTimeZone {
252    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
253        write!(f, "unknown timezone '{}'", self.0)
254    }
255}
256
257impl Error for UnknownTimeZone {}
258
259/// The zone's standard and daylight halves as the `time` module reports them.
260#[derive(Debug, Clone, PartialEq, Eq)]
261pub struct ZoneConstants {
262    /// Standard time: `-offset_seconds` is `time.timezone`, `name` is `tzname[0]`.
263    pub standard: MontyTimeZone,
264    /// Daylight time, equal to `standard` when the zone has none: `time.altzone`, `tzname[1]`.
265    pub daylight_zone: MontyTimeZone,
266    /// `time.daylight`: whether the two halves differ.
267    pub daylight: bool,
268}
269
270/// `utc` as a jiff instant, clamped to jiff's range: its timestamps end about a
271/// day before `datetime`'s do, so that every offset still renders as a civil
272/// datetime. No zone changes offset on 31 December, so the clamp reports the
273/// same offset and name the instant itself would.
274fn timestamp(utc: NaiveDateTime) -> Timestamp {
275    let utc = utc.and_utc();
276    let seconds = utc.timestamp();
277    let nanoseconds = i32::try_from(utc.timestamp_subsec_nanos()).unwrap_or(0);
278    Timestamp::new(seconds, nanoseconds).unwrap_or(if seconds < 0 { Timestamp::MIN } else { Timestamp::MAX })
279}
280
281/// `local` as a jiff civil datetime, `None` outside jiff's year range.
282fn civil_datetime(local: NaiveDateTime) -> Option<civil::DateTime> {
283    civil::DateTime::new(
284        i16::try_from(local.year()).ok()?,
285        i8::try_from(local.month()).ok()?,
286        i8::try_from(local.day()).ok()?,
287        i8::try_from(local.hour()).ok()?,
288        i8::try_from(local.minute()).ok()?,
289        i8::try_from(local.second()).ok()?,
290        i32::try_from(local.nanosecond()).ok()?,
291    )
292    .ok()
293}
294
295/// The wall clock `offset_seconds` from UTC at `utc`, or `None` outside the
296/// 1..=9999 years Python's `datetime` can hold.
297#[must_use]
298pub fn local_wall_clock(utc: NaiveDateTime, offset_seconds: i32) -> Option<NaiveDateTime> {
299    let shifted = utc.checked_add_signed(TimeDelta::seconds(i64::from(offset_seconds)))?;
300    (1..=9999).contains(&shifted.year()).then_some(shifted)
301}
302
303/// The instant as `time.time()` reports it: seconds since the Unix epoch.
304#[must_use]
305pub fn unix_seconds(utc: NaiveDateTime) -> f64 {
306    let epoch = utc.and_utc();
307    epoch.timestamp() as f64 + f64::from(epoch.timestamp_subsec_micros()) / 1_000_000.0
308}
309
310/// What the sleep calls do.
311#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
312pub enum SleepMode {
313    /// Cap each delay at this duration, then suspend to the host to wait without
314    /// its `os` handler. The host charges `ResourceLimits::max_total_sleep` before
315    /// waiting; waits do not count as execution time. Standard execution waits
316    /// inline without a cumulative limit. Longer delays are capped, not rejected.
317    System(Duration),
318    /// Delegate to the host's `os` handler without capping or charging the delay.
319    CallHost,
320    /// Return at once without waiting.
321    Zero,
322}
323
324impl SleepMode {
325    /// Default per-call cap for [`System`](Self::System).
326    pub const DEFAULT_MAX: Duration = Duration::from_secs(10);
327}
328
329impl Default for SleepMode {
330    fn default() -> Self {
331        Self::System(Self::DEFAULT_MAX)
332    }
333}
334
335/// What `time.process_time()` and `time.thread_time()` report.
336///
337/// Separate from [`DateTimeSource`] because these clocks exclude sleeps and
338/// time suspended on the host, which no wall clock can express.
339#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
340pub enum ProcessTime {
341    /// Always zero, so a [`DateTimeSource::Fixed`] session stays blind to time passing.
342    #[default]
343    Zero,
344    /// The session's accumulated execution time, as `ResourceTracker::elapsed`
345    /// measures it. Wall time while running, not CPU time.
346    Elapsed,
347}
348
349/// Where an unseeded `random` generator gets its first state.
350#[derive(Debug, Clone, PartialEq, Default, serde::Serialize, serde::Deserialize)]
351pub enum RandomStart {
352    /// From the sandbox's own OS entropy.
353    #[default]
354    System,
355    /// Request one state vector (2496 bytes) from the host via `os.urandom` on the first draw.
356    CallHost,
357    /// The module-level generator starts exactly as `random.seed(seed)` leaves
358    /// it; unseeded `random.Random()` instances take deterministic states
359    /// derived from the same seed.
360    Seed(RandomSeed),
361}
362
363/// Explicit seeds for [`RandomStart::Seed`], using CPython's `random.seed()` semantics.
364#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
365pub enum RandomSeed {
366    /// Any size; CPython seeds from the absolute value.
367    Int(BigInt),
368    /// Seeded from the float's CPython `hash()`.
369    Float(f64),
370    /// Seeded from the SHA-512-extended text, as `seed(str)` does.
371    Str(String),
372    /// Seeded from the SHA-512-extended bytes, as `seed(bytes)` does.
373    Bytes(#[serde(with = "serde_bytes")] Vec<u8>),
374}