feat(runner): persist ACPX suspension checkpoints (#12425)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The Rust ACPX session can now suspend only at a safe idle boundary
and its events have a durable projection
> - A later runner process cannot safely resume from an unbound or
partially written identity record
> - The recovery anchor must bind the exact provider identity, run,
normalized session, catalog revision, and catalog digest
> - The record must be bounded, private, strict about schema drift, and
atomically replaceable
> - Recovery must re-admit the entire prospective session configuration
before releasing the stored identity
> - This pull request adds only that package-local checkpoint store
without selecting ACPX in runnerd

## Linked Issues or Issue Description

Refs #12424

Refs #12422

## What Changed

- Add a versioned ACPX safe-suspension checkpoint contract with unknown
fields rejected at every persisted level.
- Bind each checkpoint to the run, normalized session, catalog revision,
catalog digest, and exact provider identity.
- Persist a checkpoint-specific strict identity that requires the pinned
permission mode without narrowing the additive live sidecar identity
wire shape.
- Construct checkpoints only from a session configuration whose model,
permission policy, tool catalog, and expected identity validate.
- Admit recovery only when reconstructing the checkpoint from the
prospective configuration produces an exact match.
- Reject run, session, catalog revision, catalog digest, model,
permission, expected-identity, profile, and workspace drift fail closed.
- Require persisted run/session IDs to satisfy the same stable-ID
boundary as fresh session admission.
- Store the checkpoint under a dedicated private runner-state
subdirectory.
- Bound checkpoint files to 1 MiB before reading or decoding.
- Refuse symlinked state directories and non-private or non-regular
checkpoint files.
- Replace checkpoints atomically through a private temporary file and
directory sync.
- Make repeated saves of the same checkpoint idempotent.
- Add integration coverage for private round trips, complete recovery
admission, malformed/oversized files, nested schema drift, missing
permission binding, invalid stable IDs, and symlink denial.
- Document the package-local suspension recovery boundary.
- Do not change dependencies, lockfiles, workflows, runnerd selection,
server behavior, UI, or migrations.

## Verification

- Replay base: `1ee738cf487defe88043b241c4e2dc34a1a8a7bc` (`master`
after #12424 merged).
- Exact replay head: `89cfea5495428be890810d2b8a27673943234ca3`.
- Stable patch ID: `dbaeb0bbe773f1ca5ef1f9bdc0fa61f4a08ca451`.
- The exact delta is 4 files and 591 additions, all in
`packages/paperclip-runner`; it contains no lockfile, workflow, server,
UI, dependency, or migration change.
- `git diff --check` and the Cargo formatting check pass on the replayed
delta.
- Exact-head GitHub Actions run `33374006661` (attempt 2): **PASSED**
with 23/23 jobs passed.
- Greptile reviewed exact head
`89cfea5495428be890810d2b8a27673943234ca3`: **5/5**, with zero
unresolved review threads.
- Superagent, contributor trust, Socket, and Snyk security checks:
**PASSED**.
- No local test result is claimed. GitHub Actions is the authoritative
verification environment for this replayed revision.

## Risks

- A checkpoint is valid only after the sidecar has confirmed safe
suspension. The constructor therefore accepts the exact identity
returned by that operation and revalidates it against local authority.
- Loading proves only that the file is structurally valid;
`admit_recovery` is the boundary that proves the file belongs to the
prospective run, catalog, model, permission policy, and expected
provider identity.
- The checkpoint intentionally contains no credentials, bootstrap
ticket, provider output, or pending request payload.
- Strict checkpoint schema and identity validation rejects incompatible
or tampered recovery records rather than attempting partial migration.
This strictness is checkpoint-local and does not narrow existing PRP or
sidecar wire compatibility.
- Atomic replacement uses the platform `rename` primitive; Unix
additionally syncs the private parent directory before reporting
success.
- No production path loads this checkpoint in this pull request. Runnerd
execution and durable recovery wiring remain a later slice.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex with GPT-5.6, agentic reasoning, tool use, and code
execution.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used with version and capability
details
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have linked the preceding public PRs or described the issue
in-PR
- [x] I have not referenced internal or instance-local Paperclip issues
or links
- [x] My branch name describes the change and contains no internal
Paperclip ticket id
- [ ] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
This commit is contained in:
Dotta 2026-08-31 04:12:36 -05:00 committed by GitHub
parent 1ee738cf48
commit 8478ddbcee
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 591 additions and 0 deletions

View File

@ -117,6 +117,11 @@ terminates the local process.
Already validated ACPX reducer events project into provider-neutral durable
events only with an exact run, session, turn, and item binding. Raw sidecar
envelopes and permission requests are not admitted at this boundary.
A safely suspended session can be recorded as a bounded private checkpoint.
The checkpoint binds the exact provider identity, run, catalog revision, and
catalog digest and is replaced atomically before a later recovery attempt.
Recovery releases the stored identity only after those bindings match the
prospective session configuration exactly.
Run the complete contract gate with:

View File

@ -0,0 +1,337 @@
use std::fs::{self, DirBuilder};
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
#[cfg(unix)]
use std::fs::File;
#[cfg(unix)]
use std::os::unix::fs::DirBuilderExt;
use serde::{Deserialize, Serialize};
use crate::acpx_provider_session::{
AcpxPermissionMode, AcpxProviderSessionConfig, AcpxProviderSessionIdentity,
};
use crate::durable::{
create_private_temporary_file, open_private_regular_file, verify_private_directory,
};
use crate::local_runner::LocalRunnerError;
use crate::stable_identity::{is_stable_id, SHORT_STABLE_ID_CHARS};
const CHECKPOINT_SCHEMA: &str = "paperclip.runner.acpx-suspension-checkpoint.v1";
const CHECKPOINT_DIRECTORY: &str = "acpx-provider";
const CHECKPOINT_FILE: &str = "suspension-checkpoint.json";
const MAX_CHECKPOINT_BYTES: u64 = 1024 * 1024;
const MAX_JSON_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AcpxSuspensionCheckpoint {
schema: String,
run_id: String,
normalized_session_id: String,
catalog_revision: u64,
catalog_digest: String,
identity: PersistedAcpxProviderSessionIdentity,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct PersistedAcpxProviderSessionIdentity {
kind: String,
normalized_session_id: String,
acpx_record_id: String,
backend_session_id: String,
agent_session_id: String,
profile_digest: String,
workspace_digest: String,
requested_model: String,
effective_model: String,
permission_mode: AcpxPermissionMode,
}
impl PersistedAcpxProviderSessionIdentity {
fn from_runtime(identity: AcpxProviderSessionIdentity) -> Result<Self, LocalRunnerError> {
identity.validate()?;
let permission_mode = identity.permission_mode.ok_or_else(|| {
LocalRunnerError::invalid(
"ACPX suspension checkpoint identity omitted its pinned permission mode",
)
})?;
Ok(Self {
kind: identity.kind,
normalized_session_id: identity.normalized_session_id,
acpx_record_id: identity.acpx_record_id,
backend_session_id: identity.backend_session_id,
agent_session_id: identity.agent_session_id,
profile_digest: identity.profile_digest,
workspace_digest: identity.workspace_digest,
requested_model: identity.requested_model,
effective_model: identity.effective_model,
permission_mode,
})
}
fn runtime_identity(&self) -> AcpxProviderSessionIdentity {
AcpxProviderSessionIdentity {
kind: self.kind.clone(),
normalized_session_id: self.normalized_session_id.clone(),
acpx_record_id: self.acpx_record_id.clone(),
backend_session_id: self.backend_session_id.clone(),
agent_session_id: self.agent_session_id.clone(),
profile_digest: self.profile_digest.clone(),
workspace_digest: self.workspace_digest.clone(),
requested_model: self.requested_model.clone(),
effective_model: self.effective_model.clone(),
permission_mode: Some(self.permission_mode),
}
}
fn validate(&self) -> Result<(), LocalRunnerError> {
self.runtime_identity().validate()
}
}
impl AcpxSuspensionCheckpoint {
pub fn from_suspension(
config: &AcpxProviderSessionConfig,
identity: AcpxProviderSessionIdentity,
) -> Result<Self, LocalRunnerError> {
config.validate()?;
identity.validate()?;
if identity.normalized_session_id != config.normalized_session_id
|| identity.requested_model != config.model
|| identity.effective_model != config.model
|| identity.permission_mode != Some(config.permission_mode)
|| config
.expected_identity
.as_ref()
.is_some_and(|expected| expected != &identity)
{
return Err(LocalRunnerError::invalid(
"ACPX suspended identity conflicts with the admitted session configuration",
));
}
let checkpoint = Self {
schema: CHECKPOINT_SCHEMA.to_owned(),
run_id: config.run_id.clone(),
normalized_session_id: config.normalized_session_id.clone(),
catalog_revision: config.catalog_revision,
catalog_digest: config.tool_set.catalog_digest.clone(),
identity: PersistedAcpxProviderSessionIdentity::from_runtime(identity)?,
};
checkpoint.validate()?;
Ok(checkpoint)
}
pub fn validate(&self) -> Result<(), LocalRunnerError> {
if self.schema != CHECKPOINT_SCHEMA {
return Err(LocalRunnerError::invalid(
"ACPX suspension checkpoint schema is unsupported",
));
}
validate_stable_id(&self.run_id, "run")?;
validate_stable_id(&self.normalized_session_id, "normalized session")?;
if self.catalog_revision == 0 || self.catalog_revision > MAX_JSON_SAFE_INTEGER {
return Err(LocalRunnerError::invalid(
"ACPX suspension checkpoint catalog revision is invalid",
));
}
if !is_sha256_digest(&self.catalog_digest) {
return Err(LocalRunnerError::invalid(
"ACPX suspension checkpoint catalog digest is invalid",
));
}
self.identity.validate()?;
if self.identity.normalized_session_id != self.normalized_session_id {
return Err(LocalRunnerError::invalid(
"ACPX suspension checkpoint session identity is inconsistent",
));
}
Ok(())
}
pub fn admit_recovery(
&self,
config: &AcpxProviderSessionConfig,
) -> Result<AcpxProviderSessionIdentity, LocalRunnerError> {
self.validate()?;
config.validate()?;
let identity = self.identity.runtime_identity();
let expected = Self::from_suspension(config, identity.clone())?;
if &expected != self {
return Err(LocalRunnerError::invalid(
"ACPX suspension checkpoint conflicts with the recovery configuration",
));
}
Ok(identity)
}
}
#[derive(Clone, Debug)]
pub struct AcpxSuspensionCheckpointStore {
directory: PathBuf,
path: PathBuf,
}
impl AcpxSuspensionCheckpointStore {
pub fn new(runner_state_directory: &Path) -> Result<Self, LocalRunnerError> {
secure_directory(runner_state_directory, "runner state")?;
let directory = runner_state_directory.join(CHECKPOINT_DIRECTORY);
secure_directory(&directory, "ACPX checkpoint")?;
Ok(Self {
path: directory.join(CHECKPOINT_FILE),
directory,
})
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn load(&self) -> Result<Option<AcpxSuspensionCheckpoint>, LocalRunnerError> {
let file = match open_private_regular_file(&self.path) {
Ok(file) => file,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => {
return Err(LocalRunnerError::invalid(format!(
"failed to open the private ACPX suspension checkpoint: {error}"
)))
}
};
let length = file
.metadata()
.map_err(|error| {
LocalRunnerError::invalid(format!(
"failed to inspect the ACPX suspension checkpoint: {error}"
))
})?
.len();
if length > MAX_CHECKPOINT_BYTES {
return Err(LocalRunnerError::invalid(
"ACPX suspension checkpoint exceeds its 1 MiB bound",
));
}
let bytes = read_checkpoint_bytes(file, length)?;
let checkpoint: AcpxSuspensionCheckpoint =
serde_json::from_slice(&bytes).map_err(|error| {
LocalRunnerError::invalid(format!(
"ACPX suspension checkpoint is malformed: {error}"
))
})?;
checkpoint.validate()?;
Ok(Some(checkpoint))
}
pub fn save(&self, checkpoint: &AcpxSuspensionCheckpoint) -> Result<(), LocalRunnerError> {
checkpoint.validate()?;
verify_private_directory(&self.directory).map_err(|error| {
LocalRunnerError::invalid(format!(
"ACPX checkpoint directory is no longer private: {error}"
))
})?;
let bytes = serde_json::to_vec_pretty(checkpoint).map_err(|error| {
LocalRunnerError::invalid(format!(
"failed to serialize the ACPX suspension checkpoint: {error}"
))
})?;
if bytes.len() as u64 > MAX_CHECKPOINT_BYTES {
return Err(LocalRunnerError::invalid(
"ACPX suspension checkpoint exceeds its 1 MiB bound",
));
}
let (temporary, mut file) = create_private_temporary_file(&self.path).map_err(|error| {
LocalRunnerError::invalid(format!(
"failed to create a private ACPX checkpoint file: {error}"
))
})?;
let result = (|| -> std::io::Result<()> {
file.write_all(&bytes)?;
file.sync_all()?;
drop(file);
fs::rename(&temporary, &self.path)?;
#[cfg(unix)]
File::open(&self.directory)?.sync_all()?;
Ok(())
})();
if let Err(error) = result {
let _ = fs::remove_file(&temporary);
return Err(LocalRunnerError::invalid(format!(
"failed to atomically replace the ACPX suspension checkpoint: {error}"
)));
}
Ok(())
}
}
fn read_checkpoint_bytes(
reader: impl Read,
capacity_hint: u64,
) -> Result<Vec<u8>, LocalRunnerError> {
let mut reader = reader.take(MAX_CHECKPOINT_BYTES + 1);
let mut bytes = Vec::with_capacity(capacity_hint.min(MAX_CHECKPOINT_BYTES) as usize);
reader.read_to_end(&mut bytes).map_err(|error| {
LocalRunnerError::invalid(format!(
"failed to read the ACPX suspension checkpoint: {error}"
))
})?;
if bytes.len() as u64 > MAX_CHECKPOINT_BYTES {
return Err(LocalRunnerError::invalid(
"ACPX suspension checkpoint exceeds its 1 MiB bound",
));
}
Ok(bytes)
}
fn secure_directory(path: &Path, label: &str) -> Result<(), LocalRunnerError> {
let mut builder = DirBuilder::new();
#[cfg(unix)]
builder.mode(0o700);
match builder.create(path) {
Ok(()) => {}
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
Err(error) => {
return Err(LocalRunnerError::invalid(format!(
"failed to create {label} directory: {error}"
)))
}
}
// The leaf is created with its private mode in the atomic mkdir operation.
// Never chmod a path after a metadata check: an attacker could replace the
// leaf with a symlink between those calls and redirect the permission write.
// Existing paths must already satisfy the same fail-closed contract.
verify_private_directory(path).map_err(|error| {
LocalRunnerError::invalid(format!("{label} directory is not private: {error}"))
})
}
fn validate_stable_id(value: &str, label: &str) -> Result<(), LocalRunnerError> {
if !is_stable_id(value, SHORT_STABLE_ID_CHARS) {
return Err(LocalRunnerError::invalid(format!(
"ACPX suspension checkpoint {label} identity is invalid"
)));
}
Ok(())
}
fn is_sha256_digest(value: &str) -> bool {
value.len() == 71
&& value.starts_with("sha256:")
&& value[7..]
.chars()
.all(|character| character.is_ascii_hexdigit() && !character.is_ascii_uppercase())
}
#[cfg(test)]
mod tests {
use super::read_checkpoint_bytes;
#[test]
fn bounded_checkpoint_reader_rejects_growth_beyond_the_metadata_hint() {
let error = read_checkpoint_bytes(std::io::repeat(b'x'), 0)
.unwrap_err()
.to_string();
assert!(error.contains("1 MiB"), "{error}");
}
}

View File

@ -2,6 +2,7 @@
pub mod acpx_event_payload;
pub mod acpx_event_scope;
pub mod acpx_provider_checkpoint;
pub mod acpx_provider_session;
pub mod acpx_provider_state;
pub mod acpx_sidecar_transport;

View File

@ -0,0 +1,248 @@
use std::fs;
use std::path::PathBuf;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use paperclip_runner_core::acpx_provider_checkpoint::{
AcpxSuspensionCheckpoint, AcpxSuspensionCheckpointStore,
};
use paperclip_runner_core::acpx_provider_session::{
AcpxPermissionMode, AcpxProviderSessionConfig, AcpxProviderSessionIdentity,
};
use paperclip_runner_core::acpx_sidecar_transport::AcpxSidecarTransportConfig;
use paperclip_runner_core::provider_bridge::{
authorized_tool_catalog_digest, AuthorizedTool, AuthorizedToolSet,
};
use serde_json::json;
fn temporary_directory(label: &str) -> PathBuf {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let directory = std::env::temp_dir().join(format!(
"paperclip-acpx-checkpoint-{label}-{}-{nonce}",
std::process::id()
));
fs::create_dir_all(&directory).unwrap();
#[cfg(unix)]
fs::set_permissions(&directory, fs::Permissions::from_mode(0o700)).unwrap();
directory
}
fn config(directory: &std::path::Path) -> AcpxProviderSessionConfig {
let operations = Vec::new();
AcpxProviderSessionConfig {
transport: AcpxSidecarTransportConfig {
command: PathBuf::from(env!("CARGO_BIN_EXE_fake-acpx-sidecar")),
args: vec!["--mode".to_owned(), "suspend".to_owned()],
request_timeout: Duration::from_secs(1),
shutdown_grace: Duration::from_millis(100),
},
agent: "codex".to_owned(),
model: "gpt-5.6-sol".to_owned(),
run_id: "run-1".to_owned(),
catalog_revision: 7,
runtime_directory: directory.to_owned(),
normalized_session_id: "session-1".to_owned(),
working_directory: directory.to_owned(),
permission_mode: AcpxPermissionMode::ApproveReads,
permission_mode_pinned: true,
system_instructions: "Complete the supplied task.".to_owned(),
tool_set: AuthorizedToolSet {
schema: "paperclip.runner.authorized-tools.v1".to_owned(),
schema_version: 1,
catalog_digest: authorized_tool_catalog_digest(&operations).unwrap(),
operations,
},
expected_identity: None,
}
}
fn identity() -> AcpxProviderSessionIdentity {
AcpxProviderSessionIdentity {
kind: "acpx".to_owned(),
normalized_session_id: "session-1".to_owned(),
acpx_record_id: "acpx-record-1".to_owned(),
backend_session_id: "backend-session-1".to_owned(),
agent_session_id: "agent-session-1".to_owned(),
profile_digest: format!("sha256:{}", "a".repeat(64)),
workspace_digest: format!("sha256:{}", "b".repeat(64)),
requested_model: "gpt-5.6-sol".to_owned(),
effective_model: "gpt-5.6-sol".to_owned(),
permission_mode: Some(AcpxPermissionMode::ApproveReads),
}
}
#[test]
fn round_trips_an_exact_private_suspension_checkpoint_idempotently() {
let directory = temporary_directory("round-trip");
let config = config(&directory);
let checkpoint = AcpxSuspensionCheckpoint::from_suspension(&config, identity()).unwrap();
let store = AcpxSuspensionCheckpointStore::new(&directory).unwrap();
assert_eq!(store.load().unwrap(), None);
store.save(&checkpoint).unwrap();
store.save(&checkpoint).unwrap();
let recovered = store.load().unwrap().unwrap();
assert_eq!(recovered, checkpoint);
assert_eq!(recovered.admit_recovery(&config).unwrap(), identity());
#[cfg(unix)]
{
assert_eq!(
fs::metadata(store.path()).unwrap().permissions().mode() & 0o077,
0
);
assert_eq!(
fs::metadata(store.path().parent().unwrap())
.unwrap()
.permissions()
.mode()
& 0o077,
0
);
}
fs::remove_dir_all(directory).unwrap();
}
#[test]
fn admits_recovery_only_for_the_exact_run_catalog_and_provider_identity() {
let directory = temporary_directory("recovery-admission");
let config = config(&directory);
let checkpoint = AcpxSuspensionCheckpoint::from_suspension(&config, identity()).unwrap();
let mut changed_run = config.clone();
changed_run.run_id = "run-2".to_owned();
let mut changed_session = config.clone();
changed_session.normalized_session_id = "session-2".to_owned();
let mut changed_revision = config.clone();
changed_revision.catalog_revision += 1;
let mut changed_catalog = config.clone();
let operations = vec![AuthorizedTool {
operation_id: "issues.read".to_owned(),
version: 1,
description: "Read one issue".to_owned(),
input_schema: json!({"type":"object"}),
response_schema: json!({"type":"object"}),
}];
changed_catalog.tool_set = AuthorizedToolSet {
schema: "paperclip.runner.authorized-tools.v1".to_owned(),
schema_version: 1,
catalog_digest: authorized_tool_catalog_digest(&operations).unwrap(),
operations,
};
let mut changed_model = config.clone();
changed_model.model = "gpt-5.6-sol-mini".to_owned();
let mut changed_permission = config.clone();
changed_permission.permission_mode = AcpxPermissionMode::ApproveAll;
let mut changed_expected_identity = config.clone();
let mut expected = identity();
expected.backend_session_id = "backend-session-2".to_owned();
changed_expected_identity.expected_identity = Some(expected);
for (label, changed) in [
("run", changed_run),
("session", changed_session),
("revision", changed_revision),
("catalog", changed_catalog),
("model", changed_model),
("permission", changed_permission),
("expected identity", changed_expected_identity),
] {
let error = checkpoint.admit_recovery(&changed).unwrap_err().to_string();
assert!(error.contains("conflict"), "{label}: {error}");
}
fs::remove_dir_all(directory).unwrap();
}
#[test]
fn rejects_identity_drift_before_a_checkpoint_is_created() {
let directory = temporary_directory("identity-drift");
let config = config(&directory);
let mut mismatched = identity();
mismatched.effective_model = "other-model".to_owned();
let error = AcpxSuspensionCheckpoint::from_suspension(&config, mismatched)
.unwrap_err()
.to_string();
assert!(error.contains("conflicts"), "{error}");
fs::remove_dir_all(directory).unwrap();
}
#[test]
fn fails_closed_on_unknown_or_oversized_checkpoint_files() {
let directory = temporary_directory("malformed");
let config = config(&directory);
let checkpoint = AcpxSuspensionCheckpoint::from_suspension(&config, identity()).unwrap();
let store = AcpxSuspensionCheckpointStore::new(&directory).unwrap();
store.save(&checkpoint).unwrap();
let valid: serde_json::Value =
serde_json::from_slice(&fs::read(store.path()).unwrap()).unwrap();
let mut top_level_unknown = valid.clone();
top_level_unknown["unexpected"] = json!(true);
let mut nested_unknown = valid.clone();
nested_unknown["identity"]["unexpected"] = json!(true);
let mut missing_permission = valid.clone();
missing_permission["identity"]
.as_object_mut()
.unwrap()
.remove("permissionMode");
let mut invalid_run = valid.clone();
invalid_run["runId"] = json!("run 1");
let mut invalid_session = valid;
invalid_session["normalizedSessionId"] = json!("séssion-1");
for malformed in [
top_level_unknown,
nested_unknown,
missing_permission,
invalid_run,
invalid_session,
] {
fs::write(store.path(), serde_json::to_vec(&malformed).unwrap()).unwrap();
assert!(store.load().is_err());
}
fs::write(store.path(), vec![b'x'; 1024 * 1024 + 1]).unwrap();
assert!(store.load().unwrap_err().to_string().contains("1 MiB"));
fs::remove_dir_all(directory).unwrap();
}
#[cfg(unix)]
#[test]
fn rejects_a_symlinked_runner_state_directory() {
use std::os::unix::fs::symlink;
let directory = temporary_directory("symlink");
let actual = directory.join("actual");
fs::create_dir(&actual).unwrap();
let linked = directory.join("linked");
symlink(&actual, &linked).unwrap();
let error = AcpxSuspensionCheckpointStore::new(&linked)
.unwrap_err()
.to_string();
assert!(error.contains("must not be a symlink"), "{error}");
fs::remove_dir_all(directory).unwrap();
}
#[cfg(unix)]
#[test]
fn rejects_an_existing_runner_state_directory_that_is_not_private() {
let directory = temporary_directory("public-state");
fs::set_permissions(&directory, fs::Permissions::from_mode(0o755)).unwrap();
let error = AcpxSuspensionCheckpointStore::new(&directory)
.unwrap_err()
.to_string();
assert!(error.contains("not private"), "{error}");
assert_eq!(
fs::metadata(&directory).unwrap().permissions().mode() & 0o077,
0o055
);
fs::remove_dir_all(directory).unwrap();
}