Implement retained virtual terminal presentation

This commit is contained in:
ms2k 2026-08-03 22:51:05 +09:00
commit f468592d95
12 changed files with 811 additions and 71 deletions

View file

@ -20,6 +20,7 @@ path = "tests/recovery-process.rs"
harness = false
[dependencies]
base64.workspace = true
ed25519-dalek.workspace = true
getrandom.workspace = true
indicatif.workspace = true
@ -42,6 +43,7 @@ serde.workspace = true
sha2.workspace = true
time.workspace = true
tokio.workspace = true
vt100.workspace = true
[dev-dependencies]
tempfile = "=3.27.0"

View file

@ -0,0 +1 @@
HgIjACsADwBpARYFdG11eC0yNTZjb2xvcnx0bXV4IHdpdGggMjU2IGNvbG9ycwAAAQAAAQAAAAEBAAAAAQEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAABUAAAAAgAAAAYAAAA/////////////////////////////////////////////////////wABAAAAAAEAAAAEAAYACAAZAB4AJQApAC0A//84AEkASwBPAFYA//9YAGQA//9oAGsAcQB1AHkA//9/AIEAhgCLAP//lACZAJ4A//+jAKgArQD//7IAtAC5AP//wgDHAM0A0wD//9YA///YAP///////9wA///gAP///////+IA///nAP//////////6wDvAPUA+QD9AAEBBwENARMBGQEfASQB//8pAf//LQEyATcBOwFCAf//SQFNAVUB//////////////////////////////////9dAf//YAFpAXIBewGEAY0BlgGfAagBsQH/////////////////////ugH/////ywHOAdkB3AHeAeEBOgL//z0CPwL//////////////////////////0QC//+FAv///////////////4kC/////////////////////5AC/////////////////////////////////////////////////////////////////////5UC/////5wC//////////+jAqoCsQL/////uAL//78C////////xgL/////////////zQLTAtkC4ALnAu4C9QL9AgUDDQMVAx0DJQMtAzUDPANDA0oDUQNZA2EDaQNxA3kDgQOJA5EDmAOfA6YDrQO1A70DxQPNA9UD3QPlA+0D9AP7AwIECQQRBBkEIQQpBDEEOQRBBEkEUARXBF4E/////////////////////////////////////////////////////////////2MEbgRzBHsEfwT//////////////////////////////////4gE////////////////////////jQT///////////////////////////////////////////////////////////////////////////////////////+TBP///////5cE1gQbW1oABwANABtbJWklcDElZDslcDIlZHIAG1sz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

View file

@ -9,6 +9,7 @@ use std::os::unix::fs::{FileExt, PermissionsExt};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use base64::Engine as _;
use lazaret_core::configuration::{ConfinedPathPlan, ResolvedDomainConfiguration};
use lazaret_core::identity::{
BundleDigest, ContainerId, HostPath, MountIdentity, RunscBuildId, RunscDigest,
@ -25,8 +26,8 @@ use lazaret_linux::process::{
};
use lazaret_linux::user_namespace::UserNamespaceMap;
use lazaret_runtime_supervisor::sandbox::{
RootProcess, SandboxBuildRequest, SandboxBundle, build_sandbox_bundle,
build_sandbox_bundle_with_workspace,
HostTerminal, RootProcess, SandboxBuildRequest, SandboxBundle, build_sandbox_bundle,
build_sandbox_bundle_with_workspace, stage_host_terminal,
};
use lazaret_runtime_supervisor::supervision::{
RuntimeGitRoutingAuthority, RuntimeGitRoutingLaunch, RuntimeNetworkLaunch,
@ -50,6 +51,8 @@ use crate::terminal::RunscInteractiveSessionExecutor;
use crate::waiter::{BootstrapWaitOutcome, BootstrapWaiterFace};
const NETWORK_NAMESPACE_PATH: &str = "/proc/self/ns/net";
const CONTROLLED_TERM: &str = "tmux-256color";
const CONTROLLED_TERMINFO: &str = include_str!("../assets/tmux-256color.base64");
#[derive(Debug)]
pub struct ColdStartError(String);
@ -199,13 +202,21 @@ impl<'a> ColdStart<'a> {
.map_err(|error| failure(format!("network policy: {error}")))?;
runtime_files::replace_protected_runtime_files(&state_anchor_layout, &network_policy)
.map_err(|error| failure(format!("protected runtime files: {error}")))?;
let terminal_bytes = base64::engine::general_purpose::STANDARD
.decode(CONTROLLED_TERMINFO.trim())
.map_err(|error| failure(format!("controlled terminfo decoding: {error}")))?;
let terminal = HostTerminal::from_compiled_entry(CONTROLLED_TERM, None, terminal_bytes)
.and_then(|terminal| {
stage_host_terminal(inputs.configuration, inputs.container_id, terminal)
})
.map_err(|error| failure(format!("controlled terminfo staging: {error}")))?;
let request = SandboxBuildRequest {
configuration: inputs.configuration,
runsc_program: inputs.runsc_program,
container_id: inputs.container_id,
expected_network_namespace,
root_process: inputs.root_process,
terminal: None,
terminal: Some(terminal),
prepared_root: inputs.prepared_artifact_label.to_owned(),
};
let sandbox_bundle = match &git_routing {

View file

@ -661,6 +661,7 @@ impl TerminalDomainStore for HostCoordinatorDomainStore {
mod engine;
mod interactive;
mod retained;
mod runsc;
use engine::release_physical;

View file

@ -82,6 +82,11 @@ impl SessionTerminalEngine {
process,
releasing: false,
pending_input: VecDeque::new(),
pending_render: VecDeque::new(),
retained: super::retained::RetainedTerminal::new(
request.initial_size.rows.get(),
request.initial_size.columns.get(),
),
},
);
Ok(())
@ -204,6 +209,10 @@ impl SessionTerminalEngine {
}
SessionTerminalEventV2::Resize(resize) => {
terminal.process.resize(resize)?;
terminal
.retained
.resize(resize.rows.get(), resize.columns.get());
terminal.pending_render.clear();
Ok(Vec::new())
}
SessionTerminalEventV2::Break(_) => {
@ -236,6 +245,12 @@ impl SessionTerminalEngine {
if authoritative != TerminalRoute::Sandbox || terminal.releasing {
return Ok(Vec::new());
}
if let Some(restoration) = terminal.retained.take_restoration() {
terminal.pending_render.extend(restoration);
}
if let Some(message) = take_rendered_output(terminal, session_id) {
return Ok(vec![message]);
}
flush_input(terminal)?;
let mut bytes = [0_u8; MAX_OUTPUT_BYTES];
match terminal.process.read_output(&mut bytes) {
@ -246,10 +261,18 @@ impl SessionTerminalEngine {
true,
GuestReleaseAuthority::Validate,
),
Ok(length) => Ok(vec![SessionTerminalMessage::Output(TerminalOutput {
session_id,
bytes: bytes[..length].to_vec(),
})]),
Ok(length) => {
let (rendered, replies) = terminal.retained.process(&bytes[..length]);
if terminal.pending_input.len() + replies.len() > MAX_INPUT_QUEUE_BYTES {
return Err(TerminalError::Backpressure);
}
terminal.pending_input.extend(replies);
flush_input(terminal)?;
terminal.pending_render.extend(rendered);
Ok(take_rendered_output(terminal, session_id)
.into_iter()
.collect())
}
Err(error) if error.kind() == io::ErrorKind::WouldBlock => Ok(Vec::new()),
Err(_) => self.close(
session_id,
@ -345,7 +368,12 @@ impl SessionTerminalEngine {
Err(with_cleanup(error, cleanup))
}
Ok(route) => {
self.active_mut(session_id, generation_id)?.route = route;
let terminal = self.active_mut(session_id, generation_id)?;
if terminal.route == TerminalRoute::Sandbox && route != TerminalRoute::Sandbox {
terminal.pending_render.clear();
terminal.retained.obscure_client();
}
terminal.route = route;
Ok(route)
}
}
@ -403,6 +431,7 @@ impl SessionTerminalEngine {
.require_guest_signal_authority(&terminal.request, &terminal.record)
.is_ok()));
let result = (|| {
let presentation_route = terminal.route;
terminal.releasing = true;
terminal.route = TerminalRoute::Detached;
let release =
@ -418,12 +447,33 @@ impl SessionTerminalEngine {
.require_guest_signal_authority(&terminal.request, &terminal.record)
.is_ok()
};
let (mut output, exit, guest_absent) = release_physical(
let (output, exit, guest_absent) = release_physical(
terminal.process.as_mut(),
session_id,
connected,
&mut authorize_guest,
)?;
let mut rendered = terminal.pending_render.drain(..).collect::<Vec<_>>();
if connected && presentation_route != TerminalRoute::Sandbox {
terminal.retained.obscure_client();
rendered.extend(terminal.retained.take_restoration().unwrap_or_default());
}
for message in output {
if let SessionTerminalMessage::Output(output) = message {
let (bytes, _) = terminal.retained.process(&output.bytes);
rendered.extend(bytes);
}
}
rendered.truncate(MAX_DRAIN_BYTES);
let mut output = rendered
.chunks(MAX_OUTPUT_BYTES)
.map(|bytes| {
SessionTerminalMessage::Output(TerminalOutput {
session_id,
bytes: bytes.to_vec(),
})
})
.collect::<Vec<_>>();
if guest_absent {
let clean = clean_terminal(generation_id, session_id)?;
self.recorder.clean(clean)?;
@ -442,6 +492,19 @@ impl SessionTerminalEngine {
}
}
fn take_rendered_output(
terminal: &mut ActiveTerminal,
session_id: SessionId,
) -> Option<SessionTerminalMessage> {
let length = terminal.pending_render.len().min(MAX_OUTPUT_BYTES);
(length != 0).then(|| {
SessionTerminalMessage::Output(TerminalOutput {
session_id,
bytes: terminal.pending_render.drain(..length).collect(),
})
})
}
pub(super) fn release_physical(
process: &mut dyn SessionTerminalProcess,
session_id: SessionId,

View file

@ -1,3 +1,4 @@
use super::retained::RetainedTerminal;
use super::{
Arc, BTreeMap, ContainerId, ControlError, CoordinatorId, DomainId, EnvironmentValue,
GenerationId, HashMap, HostCoordinatorDomainStore, HostCoordinatorTerminalRecorder,
@ -18,6 +19,8 @@ pub(super) struct ActiveTerminal {
pub(super) process: Box<dyn SessionTerminalProcess>,
pub(super) releasing: bool,
pub(super) pending_input: VecDeque<u8>,
pub(super) pending_render: VecDeque<u8>,
pub(super) retained: RetainedTerminal,
}
pub struct SessionTerminalEngine {

View file

@ -0,0 +1,177 @@
const SYNCHRONIZED_OUTPUT_START: &[u8] = b"\x1b[?2026h";
const SYNCHRONIZED_OUTPUT_END: &[u8] = b"\x1b[?2026l";
#[derive(Default)]
struct TerminalCallbacks {
replies: Vec<u8>,
}
impl vt100::Callbacks for TerminalCallbacks {
fn unhandled_csi(
&mut self,
screen: &mut vt100::Screen,
intermediate_one: Option<u8>,
intermediate_two: Option<u8>,
parameters: &[&[u16]],
character: char,
) {
let first = parameters
.first()
.and_then(|parameter| parameter.first())
.copied()
.unwrap_or(0);
if intermediate_one.is_none()
&& intermediate_two.is_none()
&& character == 'n'
&& first == 6
{
let (row, column) = screen.cursor_position();
self.replies
.extend_from_slice(format!("\x1b[{};{}R", row + 1, column + 1).as_bytes());
} else if intermediate_one.is_none()
&& intermediate_two.is_none()
&& character == 'c'
&& first == 0
{
self.replies.extend_from_slice(b"\x1b[?1;2c");
}
}
}
pub(super) struct RetainedTerminal {
parser: vt100::Parser<TerminalCallbacks>,
client_initialized: bool,
restore_pending: bool,
}
impl RetainedTerminal {
pub(super) fn new(rows: u16, columns: u16) -> Self {
Self {
parser: vt100::Parser::new_with_callbacks(
rows,
columns,
0,
TerminalCallbacks::default(),
),
client_initialized: false,
restore_pending: false,
}
}
pub(super) fn process(&mut self, bytes: &[u8]) -> (Vec<u8>, Vec<u8>) {
let previous = self.parser.screen().clone();
self.parser.process(bytes);
let rendered = if self.client_initialized {
self.parser.screen().state_diff(&previous)
} else {
self.client_initialized = true;
self.redraw()
};
let replies = std::mem::take(&mut self.parser.callbacks_mut().replies);
(rendered, replies)
}
pub(super) fn resize(&mut self, rows: u16, columns: u16) {
self.parser.screen_mut().set_size(rows, columns);
if self.client_initialized {
self.restore_pending = true;
}
}
pub(super) fn obscure_client(&mut self) {
self.restore_pending = true;
}
pub(super) fn take_restoration(&mut self) -> Option<Vec<u8>> {
if !self.restore_pending {
return None;
}
self.restore_pending = false;
self.client_initialized = true;
Some(self.redraw())
}
fn redraw(&self) -> Vec<u8> {
let mut rendered = Vec::new();
rendered.extend_from_slice(SYNCHRONIZED_OUTPUT_START);
rendered.extend_from_slice(b"\x1b[2J\x1b[H");
rendered.extend(self.parser.screen().state_formatted());
rendered.extend_from_slice(SYNCHRONIZED_OUTPUT_END);
rendered
}
}
#[cfg(test)]
mod tests {
use super::RetainedTerminal;
#[test]
fn parser_and_rendering_are_fragmentation_independent() {
let bytes = b"\x1b[2J\x1b[Hplain\xe7\x95\x8c\x1b[2;3H\x1b[1;38;2;12;34;56mstyled\x1b[0m";
let expected = retained_state(&[bytes]);
for split in 0..=bytes.len() {
assert_eq!(
retained_state(&[&bytes[..split], &bytes[split..]]),
expected
);
}
}
#[test]
fn restoration_reproduces_normal_and_alternate_screen_state() {
for bytes in [
b"normal\x1b[3;4H\x1b[?25l".as_slice(),
b"normal\x1b[?1049h\x1b[2J\x1b[Halternate\x1b[4;5H".as_slice(),
] {
let mut terminal = RetainedTerminal::new(8, 32);
let (initial, _) = terminal.process(bytes);
terminal.obscure_client();
let restored = terminal.take_restoration().unwrap();
assert_eq!(rendered_state(&initial), rendered_state(&restored));
}
}
#[test]
fn terminal_replies_derive_from_retained_cursor() {
let mut terminal = RetainedTerminal::new(24, 80);
let (_, replies) = terminal.process(b"\x1b[7;11H\x1b[6n\x1b[c");
assert_eq!(replies, b"\x1b[7;11R\x1b[?1;2c");
}
#[test]
fn resize_restores_at_the_new_size() {
let mut terminal = RetainedTerminal::new(8, 32);
terminal.process(b"content\x1b[6;7H");
terminal.resize(6, 24);
let restored = terminal.take_restoration().unwrap();
let mut parser = vt100::Parser::new(6, 24, 0);
parser.process(&restored);
assert_eq!(parser.screen().size(), (6, 24));
assert!(parser.screen().contents().contains("content"));
}
#[test]
fn prompt_before_guest_output_restores_a_blank_sandbox_screen() {
let mut terminal = RetainedTerminal::new(8, 32);
terminal.obscure_client();
let restored = terminal.take_restoration().unwrap();
let mut parser = vt100::Parser::new(8, 32, 0);
parser.process(&restored);
assert!(parser.screen().contents().trim().is_empty());
}
fn retained_state(chunks: &[&[u8]]) -> Vec<u8> {
let mut terminal = RetainedTerminal::new(8, 32);
for chunk in chunks {
terminal.process(chunk);
}
terminal.obscure_client();
terminal.take_restoration().unwrap()
}
fn rendered_state(bytes: &[u8]) -> Vec<u8> {
let mut parser = vt100::Parser::new(8, 32, 0);
parser.process(bytes);
parser.screen().state_formatted()
}
}

View file

@ -59,6 +59,9 @@ impl SessionTerminalFactory for RunscSessionTerminalFactory {
.argument(format!("PATH={}", crate::sandbox_init::DEFAULT_AGENT_PATH));
}
for (name, value) in &request.environment {
if matches!(name.as_str(), "TERM" | "TERMINFO" | "TERMINFO_DIRS") {
continue;
}
let mut assignment = OsString::from(name);
assignment.push("=");
assignment.push(value.expose());
@ -70,6 +73,10 @@ impl SessionTerminalFactory for RunscSessionTerminalFactory {
request.internal_pid_file.display()
))
.argument("-env")
.argument("TERM=tmux-256color")
.argument("-env")
.argument("TERMINFO_DIRS=/run/lazaret/terminfo:")
.argument("-env")
.argument(format!(
"LAZARET_SESSION_ID={}",
hex_lower(request.session_id.as_bytes())

View file

@ -237,6 +237,7 @@ fn insert_attached_sessions(store: &HostCoordinatorDomainStore, session_ids: &[S
struct MockState {
output: VecDeque<Vec<u8>>,
input: Vec<u8>,
log: Arc<Mutex<Vec<String>>>,
guest: MockGuestState,
resources: MockResourceState,
@ -332,9 +333,9 @@ impl TerminalDomainStore for FailingRunningStore {
impl SessionTerminalProcess for MockProcess {
fn write_input(&mut self, bytes: &[u8]) -> io::Result<usize> {
self.state
.lock()
.unwrap()
let state = &mut *self.state.lock().unwrap();
state.input.extend_from_slice(bytes);
state
.log
.lock()
.unwrap()
@ -499,6 +500,7 @@ impl SessionTerminalFactory for MockFactory {
recorder.put(running.clone())?;
let state = Arc::new(Mutex::new(MockState {
output: VecDeque::new(),
input: Vec::new(),
log: Arc::clone(&self.log),
guest: MockGuestState {
group_present: true,

View file

@ -570,6 +570,7 @@ fn running_store_failures_release_every_live_partial_handle() {
.unwrap();
let state = Arc::new(Mutex::new(MockState {
output: VecDeque::new(),
input: Vec::new(),
log: Arc::new(Mutex::new(Vec::new())),
guest: MockGuestState {
group_present: true,

View file

@ -24,26 +24,79 @@ fn merged_output_is_ordered_and_prompt_route_isolated_by_session() {
.set_route(first, GenerationId::new(7), TerminalRoute::Prompt)
.unwrap();
assert!(engine.poll(first, GenerationId::new(7)).unwrap().is_empty());
assert_eq!(
engine.poll(second, GenerationId::new(7)).unwrap(),
vec![SessionTerminalMessage::Output(TerminalOutput {
session_id: second,
bytes: b"other".to_vec(),
})]
);
let second_output = output_bytes(engine.poll(second, GenerationId::new(7)).unwrap());
assert_eq!(screen_contents(&second_output), "other");
fixture.set_session_phase(first, SessionPhase::Attached);
engine
.set_route(first, GenerationId::new(7), TerminalRoute::Sandbox)
.unwrap();
let mut merged = Vec::new();
for _ in 0..2 {
let mut rendered = Vec::new();
for _ in 0..3 {
let messages = engine.poll(first, GenerationId::new(7)).unwrap();
let [SessionTerminalMessage::Output(output)] = messages.as_slice() else {
panic!("expected one merged output message");
};
merged.extend_from_slice(&output.bytes);
rendered.extend_from_slice(&output.bytes);
}
assert_eq!(merged, b"onetwo");
assert_eq!(screen_contents(&rendered), "onetwo");
}
#[test]
fn prompt_and_host_process_presentations_restore_the_retained_screen() {
let session = id(6);
let (fixture, factory, mut engine) = setup(&[session]);
engine.acquire(&acquisition(session)).unwrap();
factory.state(0).lock().unwrap().output.push_back(
b"\x1b[2J\x1b[Hnormal\x1b[?1049h\x1b[2J\x1b[H\x1b[1;38;2;12;34;56malternate\x1b[0m\x1b[4;5H\x1b[?25l".to_vec(),
);
let initial = output_bytes(engine.poll(session, GenerationId::new(7)).unwrap());
let mut client = vt100::Parser::new(24, 80, 0);
client.process(&initial);
let expected = client.screen().state_formatted();
fixture.set_session_phase(session, SessionPhase::Prompting);
engine
.set_route(session, GenerationId::new(7), TerminalRoute::Prompt)
.unwrap();
client.process(b"\x1b[2J\x1b[HPROMPT\x1b[24;80H");
fixture.set_session_phase(session, SessionPhase::HostExecuting);
engine
.set_route(session, GenerationId::new(7), TerminalRoute::HostProcess)
.unwrap();
client.process(b"\x1b[2J\x1b[HHOST PROCESS\x1b[?25h");
assert!(
engine
.poll(session, GenerationId::new(7))
.unwrap()
.is_empty()
);
fixture.set_session_phase(session, SessionPhase::Attached);
engine
.set_route(session, GenerationId::new(7), TerminalRoute::Sandbox)
.unwrap();
let restored = output_bytes(engine.poll(session, GenerationId::new(7)).unwrap());
client.process(&restored);
assert_eq!(client.screen().state_formatted(), expected);
assert!(factory.state(0).lock().unwrap().input.is_empty());
}
#[test]
fn terminal_queries_reply_to_the_guest_from_retained_state() {
let session = id(6);
let (_fixture, factory, mut engine) = setup(&[session]);
engine.acquire(&acquisition(session)).unwrap();
factory
.state(0)
.lock()
.unwrap()
.output
.push_back(b"\x1b[7;11H\x1b[6n\x1b[c".to_vec());
engine.poll(session, GenerationId::new(7)).unwrap();
assert_eq!(
factory.state(0).lock().unwrap().input,
b"\x1b[7;11R\x1b[?1;2c"
);
}
#[test]
@ -66,14 +119,16 @@ fn close_cancels_route_then_bounds_drain_and_emits_one_final_exit() {
}),
)
.unwrap();
let bytes = messages
.iter()
.filter_map(|message| match message {
SessionTerminalMessage::Output(output) => Some(output.bytes.len()),
SessionTerminalMessage::Exit(_) => None,
})
.sum::<usize>();
assert_eq!(bytes, MAX_DRAIN_BYTES);
assert!(messages.iter().all(|message| match message {
SessionTerminalMessage::Output(output) => output.bytes.len() <= MAX_OUTPUT_BYTES,
SessionTerminalMessage::Exit(_) => true,
}));
assert_eq!(factory.state(0).lock().unwrap().output.len(), 6);
assert!(
screen_contents(&output_bytes(messages.clone()))
.chars()
.all(|character| character == 'x' || character == '\n')
);
assert!(
matches!(messages.last(), Some(SessionTerminalMessage::Exit(exit)) if exit.exit_code() == Some(17))
);
@ -104,6 +159,23 @@ fn close_cancels_route_then_bounds_drain_and_emits_one_final_exit() {
);
}
fn output_bytes(messages: Vec<SessionTerminalMessage>) -> Vec<u8> {
messages
.into_iter()
.filter_map(|message| match message {
SessionTerminalMessage::Output(output) => Some(output.bytes),
SessionTerminalMessage::Exit(_) => None,
})
.flatten()
.collect()
}
fn screen_contents(bytes: &[u8]) -> String {
let mut parser = vt100::Parser::new(24, 80, 0);
parser.process(bytes);
parser.screen().contents()
}
#[test]
fn disconnect_component_loss_and_hard_hc_loss_never_reattach() {
for loss in [

View file

@ -344,13 +344,26 @@ fn test_user_namespace_map() -> UserNamespaceMap {
fn assert_private_artifacts(bundle: &Path, control: &Path) {
let config = bundle.join("config.json");
let escalation_client = control.join("bin/lazaret-escalate");
let terminfo = control.join("terminfo/t/tmux-256color");
assert!(config.is_file());
assert!(escalation_client.is_file());
assert!(terminfo.is_file());
assert!(control.join("credentials").is_dir());
assert_eq!(mode(bundle), 0o700);
assert_eq!(mode(control), 0o700);
assert_eq!(mode(&config), 0o600);
assert_eq!(mode(&escalation_client), 0o700);
assert_eq!(mode(&terminfo), 0o400);
let specification: serde_json::Value =
serde_json::from_slice(&fs::read(config).unwrap()).unwrap();
let mount = specification["mounts"]
.as_array()
.unwrap()
.iter()
.find(|mount| mount["destination"] == "/run/lazaret/terminfo/t/tmux-256color")
.unwrap();
assert_eq!(mount["source"], terminfo.display().to_string());
assert!(mount["options"].as_array().unwrap().contains(&"ro".into()));
}
fn mode(path: &Path) -> u32 {