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}