feat: complete Steam execution

This commit is contained in:
2026-07-20 15:51:11 -04:00
parent bc0090c63d
commit 7d1077c0cb
13 changed files with 438 additions and 83 deletions
+91 -34
View File
@@ -347,27 +347,91 @@ pub struct LaunchOutcome {
pub success: bool,
}
/// Executes a validated native launch plan and captures stdout/stderr in a private log.
/// Result of submitting work to a provider-owned client.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct SubmissionOutcome {
/// Process ID of the submitted client command.
pub process_id: u32,
}
/// Submits a validated launch plan without equating the client process with the game.
///
/// # Errors
///
/// Returns a structured error when the log cannot be secured or the command cannot start.
pub fn submit(plan: &LaunchPlanV1, log_path: &Path) -> Result<SubmissionOutcome, ContractErrorV1> {
if !plan.wrappers().is_empty() {
return Err(ContractErrorV1::unsupported_capability(
text("wrapper execution is not implemented"),
name("wrappers"),
));
}
let mut log = open_log(plan, log_path)?;
let executable = executable_path(&plan.command().executable);
let child = Command::new(executable)
.args(plan.command().arguments.iter().map(CommandArgument::as_str))
.env_clear()
.envs(
plan.environment()
.iter()
.map(|(key, value)| (key.as_str(), value.as_str())),
)
.stdin(Stdio::null())
.current_dir(plan.working_directory().as_path())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(|error| launch_failed(format!("submit process: {error}")))?;
let outcome = SubmissionOutcome {
process_id: child.id(),
};
writeln!(log, "submitted {}", outcome.process_id)
.map_err(|error| unavailable(format!("write launch log: {error}")))?;
Ok(outcome)
}
/// Executes a validated launch plan and captures stdout/stderr in a private log.
///
/// # Errors
///
/// Returns a structured error when the log cannot be secured, the command cannot start,
/// or the child exits unsuccessfully.
pub fn execute(plan: &LaunchPlanV1, log_path: &Path) -> Result<LaunchOutcome, ContractErrorV1> {
let mut log = open_log(plan, log_path)?;
let (status, stdout, stderr) = spawn(plan)?;
write_redacted_output(&mut log, plan, "stdout", &stdout)?;
write_redacted_output(&mut log, plan, "stderr", &stderr)?;
writeln!(log, "exit {:?}", status.code())
.map_err(|error| unavailable(format!("write native launch log: {error}")))?;
let outcome = LaunchOutcome {
exit_code: status.code(),
success: status.success(),
};
if outcome.success {
Ok(outcome)
} else {
Err(ContractErrorV1::new(
ContractErrorCode::LaunchFailed,
text("process exited unsuccessfully; see launch log"),
))
}
}
fn open_log(plan: &LaunchPlanV1, log_path: &Path) -> Result<File, ContractErrorV1> {
let parent = log_path
.parent()
.ok_or_else(|| invalid("native log path has no parent"))?;
.ok_or_else(|| invalid("launch log path has no parent"))?;
if !log_path.is_absolute()
|| fs::symlink_metadata(log_path).is_ok_and(|m| m.file_type().is_symlink())
{
return Err(invalid("native log path is unsafe"));
return Err(invalid("launch log path is unsafe"));
}
reject_symlink_components(parent)?;
fs::create_dir_all(parent)
.map_err(|error| unavailable(format!("create native log directory: {error}")))?;
.map_err(|error| unavailable(format!("create launch log directory: {error}")))?;
reject_symlink_components(parent)?;
fs::set_permissions(parent, fs::Permissions::from_mode(DIRECTORY_MODE))
.map_err(|error| unavailable(format!("secure native log directory: {error}")))?;
.map_err(|error| unavailable(format!("secure launch log directory: {error}")))?;
let mut log = OpenOptions::new()
.create(true)
.append(true)
@@ -388,23 +452,7 @@ pub fn execute(plan: &LaunchPlanV1, log_path: &Path) -> Result<LaunchOutcome, Co
}
writeln!(log, "launch {}", plan.game_id())
.map_err(|error| unavailable(format!("write native launch log: {error}")))?;
let (status, stdout, stderr) = spawn(plan)?;
write_redacted_output(&mut log, plan, "stdout", &stdout)?;
write_redacted_output(&mut log, plan, "stderr", &stderr)?;
writeln!(log, "exit {:?}", status.code())
.map_err(|error| unavailable(format!("write native launch log: {error}")))?;
let outcome = LaunchOutcome {
exit_code: status.code(),
success: status.success(),
};
if outcome.success {
Ok(outcome)
} else {
Err(ContractErrorV1::new(
ContractErrorCode::LaunchFailed,
text("native process exited unsuccessfully; see launch log"),
))
}
Ok(log)
}
fn spawn(
@@ -412,7 +460,7 @@ fn spawn(
) -> Result<(ExitStatus, CapturedOutput, CapturedOutput), ContractErrorV1> {
if !plan.wrappers().is_empty() {
return Err(ContractErrorV1::unsupported_capability(
text("native wrapper execution is not implemented"),
text("wrapper execution is not implemented"),
name("wrappers"),
));
}
@@ -432,28 +480,28 @@ fn spawn(
.stderr(Stdio::piped());
let mut child = command
.spawn()
.map_err(|error| launch_failed(format!("start native process: {error}")))?;
.map_err(|error| launch_failed(format!("start process: {error}")))?;
let stdout = child
.stdout
.take()
.ok_or_else(|| launch_failed("capture native stdout"))?;
.ok_or_else(|| launch_failed("capture process stdout"))?;
let stderr = child
.stderr
.take()
.ok_or_else(|| launch_failed("capture native stderr"))?;
.ok_or_else(|| launch_failed("capture process stderr"))?;
let stdout_reader = thread::spawn(move || capture_bounded(stdout));
let stderr_reader = thread::spawn(move || capture_bounded(stderr));
let status = child
.wait()
.map_err(|error| launch_failed(format!("wait for native process: {error}")))?;
.map_err(|error| launch_failed(format!("wait for process: {error}")))?;
let stdout = stdout_reader
.join()
.map_err(|_| launch_failed("native stdout capture failed"))?
.map_err(|error| launch_failed(format!("read native stdout: {error}")))?;
.map_err(|_| launch_failed("stdout capture failed"))?
.map_err(|error| launch_failed(format!("read stdout: {error}")))?;
let stderr = stderr_reader
.join()
.map_err(|_| launch_failed("native stderr capture failed"))?
.map_err(|error| launch_failed(format!("read native stderr: {error}")))?;
.map_err(|_| launch_failed("stderr capture failed"))?
.map_err(|error| launch_failed(format!("read stderr: {error}")))?;
Ok((status, stdout, stderr))
}
@@ -527,11 +575,11 @@ fn reject_symlink_components(path: &Path) -> Result<(), ContractErrorV1> {
current.push(component);
match fs::symlink_metadata(&current) {
Ok(metadata) if metadata.file_type().is_symlink() => {
return Err(invalid("native log path contains a symlink"));
return Err(invalid("launch log path contains a symlink"));
}
Ok(_) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(unavailable(format!("inspect native log path: {error}"))),
Err(error) => return Err(unavailable(format!("inspect launch log path: {error}"))),
}
}
Ok(())
@@ -815,6 +863,15 @@ fn config_home(xdg: Option<&OsStr>, home: Option<&OsStr>) -> Result<PathBuf, Con
///
/// Returns a structured error when no absolute XDG state or home path is available.
pub fn current_log_path() -> Result<PathBuf, ContractErrorV1> {
current_log_path_for(&name("native"))
}
/// Resolves a provider-named private per-user launch log.
///
/// # Errors
///
/// Returns a structured error when no absolute XDG state or home path is available.
pub fn current_log_path_for(provider: &ContractName) -> Result<PathBuf, ContractErrorV1> {
let xdg = env::var_os("XDG_STATE_HOME");
let home = env::var_os("HOME");
xdg.as_deref()
@@ -827,7 +884,7 @@ pub fn current_log_path() -> Result<PathBuf, ContractErrorV1> {
.filter(|path| path.is_absolute())
.map(|path| path.join(".local/state"))
})
.map(|path| path.join("kiln/logs/native.log"))
.map(|path| path.join(format!("kiln/logs/{}.log", provider.as_str())))
.ok_or_else(|| unavailable("no absolute XDG state or home directory is available"))
}