Skip to content

Commit d53e1fd

Browse files
committed
fix(agent): preserve publication source and target semantics
1 parent b8a25d2 commit d53e1fd

2 files changed

Lines changed: 152 additions & 20 deletions

File tree

src/agent_publish.rs

Lines changed: 35 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ use crate::catalog_transaction::sync_dir;
2121
const SCHEMA: &str = "st2.agent-publish.v2";
2222
const DIGEST_SCHEMA: &str = "st2.agent-source-digest.v1";
2323
const BUNDLE_DIGEST_DOMAIN: &[u8] = b"st2.agent-publish-bundle.v1\0";
24+
const CANONICAL_DECLARATION_MODE: u32 = 0o644;
2425

2526
#[derive(Debug, Clone)]
2627
pub enum PublishSource {
@@ -80,6 +81,12 @@ struct Candidate {
8081
input_sha256: String,
8182
}
8283

84+
#[derive(Debug)]
85+
struct ExistingSpec {
86+
bytes: Vec<u8>,
87+
mode: u32,
88+
}
89+
8390
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
8491
#[serde(rename_all = "lowercase")]
8592
pub enum SourceKind {
@@ -124,14 +131,6 @@ impl Candidate {
124131
}
125132
};
126133
let spec_path = stage.path().join("agent.kdl");
127-
anyhow::ensure!(
128-
match &source {
129-
PublishSource::Spec(path) =>
130-
path.extension().and_then(|value| value.to_str()) == Some("kdl"),
131-
PublishSource::Bundle(_) => true,
132-
},
133-
"published spec must be canonical KDL"
134-
);
135134
let metadata = fs::symlink_metadata(&spec_path)
136135
.with_context(|| format!("read candidate spec {}", spec_path.display()))?;
137136
anyhow::ensure!(
@@ -236,16 +235,22 @@ pub fn publish(request: PublishRequest) -> Result<PublishResult> {
236235
.join(&candidate.identity);
237236
let target_spec = target_dir.join("agent.kdl");
238237
validate_existing_ancestry(&catalog, &target_dir)?;
239-
let before = read_regular_optional(&target_spec)?;
240-
let same_spec = before.as_deref() == Some(candidate.bytes.as_slice());
241-
let before_hash = before.as_deref().map(sha256);
238+
let before = read_existing_spec(&target_spec)?;
239+
let same_spec = before
240+
.as_ref()
241+
.is_some_and(|current| current.bytes == candidate.bytes);
242+
let before_hash = before.as_ref().map(|current| sha256(&current.bytes));
243+
let target_mode = before
244+
.as_ref()
245+
.map(|current| current.mode)
246+
.unwrap_or(CANONICAL_DECLARATION_MODE);
242247
let after_hash = sha256(&candidate.bytes);
243248

244249
match &request.expectation {
245250
PublishExpectation::Absent => {
246251
if let Some(current) = &before {
247252
anyhow::ensure!(
248-
current == &candidate.bytes,
253+
current.bytes == candidate.bytes,
249254
"publish precondition failed: {} already exists with sha256 {}",
250255
target_spec.display(),
251256
before_hash.as_deref().unwrap_or("<unreadable>")
@@ -331,6 +336,7 @@ pub fn publish(request: PublishRequest) -> Result<PublishResult> {
331336
&target_spec,
332337
&candidate.bytes,
333338
before.is_some(),
339+
target_mode,
334340
)?;
335341
}
336342
CandidateKind::Bundle => {
@@ -448,17 +454,24 @@ fn sha256(bytes: &[u8]) -> String {
448454
format!("{:x}", Sha256::digest(bytes))
449455
}
450456

451-
fn read_regular_optional(path: &Path) -> Result<Option<Vec<u8>>> {
452-
match fs::symlink_metadata(path) {
453-
Ok(metadata) => {
457+
fn read_existing_spec(path: &Path) -> Result<Option<ExistingSpec>> {
458+
match OpenOptions::new()
459+
.read(true)
460+
.custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_NONBLOCK)
461+
.open(path)
462+
{
463+
Ok(mut file) => {
464+
let metadata = file.metadata()?;
454465
anyhow::ensure!(
455-
metadata.is_file() && !metadata.file_type().is_symlink(),
466+
metadata.is_file(),
456467
"publication target is not a regular file: {}",
457468
path.display()
458469
);
459-
Ok(Some(
460-
fs::read(path).with_context(|| format!("read {}", path.display()))?,
461-
))
470+
let mode = metadata.permissions().mode() & 0o7777;
471+
let mut bytes = Vec::new();
472+
file.read_to_end(&mut bytes)
473+
.with_context(|| format!("read {}", path.display()))?;
474+
Ok(Some(ExistingSpec { bytes, mode }))
462475
}
463476
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
464477
Err(error) => Err(error).with_context(|| format!("read {}", path.display())),
@@ -674,6 +687,7 @@ fn atomic_write_spec(
674687
target: &Path,
675688
bytes: &[u8],
676689
replace: bool,
690+
mode: u32,
677691
) -> Result<()> {
678692
let parent = target.parent().context("spec target has no parent")?;
679693
let control = crate::catalog_transaction::retained_dir_path(control_file)?;
@@ -682,6 +696,8 @@ fn atomic_write_spec(
682696
.tempfile_in(&control)
683697
.with_context(|| format!("create temporary spec in {}", control.display()))?;
684698
temp.write_all(bytes)?;
699+
temp.as_file()
700+
.set_permissions(fs::Permissions::from_mode(mode))?;
685701
temp.as_file().sync_all()?;
686702
test_crash_after_temporary_write();
687703
if replace {

tests/agent_publish.rs

Lines changed: 117 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
use std::fs;
22
use std::io::Write as _;
3-
use std::os::unix::process::ExitStatusExt as _;
3+
use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
4+
use std::os::unix::process::{CommandExt as _, ExitStatusExt as _};
45
use std::path::{Path, PathBuf};
56
use std::process::Stdio;
67
use std::process::{Command, Output};
@@ -113,6 +114,121 @@ fn spec_create_is_typed_and_idempotent() {
113114
assert_eq!(second["status"], "unchanged");
114115
}
115116

117+
#[test]
118+
fn spec_source_filename_is_not_semantic_after_strict_parsing() {
119+
let temp = tempfile::tempdir().unwrap();
120+
let catalog = temp.path().join("catalog");
121+
fs::create_dir(&catalog).unwrap();
122+
let candidate = temp.path().join("agent.kdl.candidate");
123+
fs::write(&candidate, valid_spec(false)).unwrap();
124+
125+
let digest = source_digest("--spec", &candidate);
126+
let published = publish(&catalog, &candidate, &["--expect-absent"]);
127+
assert!(
128+
published.status.success(),
129+
"{}",
130+
String::from_utf8_lossy(&published.stderr)
131+
);
132+
assert_eq!(digest, sha256(valid_spec(false).as_bytes()));
133+
134+
let malformed = temp.path().join("malformed.candidate");
135+
fs::write(&malformed, "agent \"worker\" {").unwrap();
136+
let rejected = st2()
137+
.args(["agent", "digest", "--spec"])
138+
.arg(&malformed)
139+
.output()
140+
.unwrap();
141+
assert!(!rejected.status.success());
142+
assert!(
143+
String::from_utf8_lossy(&rejected.stderr).contains("strict declaration parsing"),
144+
"{}",
145+
String::from_utf8_lossy(&rejected.stderr)
146+
);
147+
}
148+
149+
#[test]
150+
fn spec_creation_uses_the_canonical_readable_declaration_mode() {
151+
let temp = tempfile::tempdir().unwrap();
152+
let catalog = temp.path().join("catalog");
153+
fs::create_dir(&catalog).unwrap();
154+
let candidate = temp.path().join("candidate.kdl");
155+
fs::write(&candidate, valid_spec(false)).unwrap();
156+
let input_sha256 = sha256(&fs::read(&candidate).unwrap());
157+
158+
let mut command = st2();
159+
command.args([
160+
"agent",
161+
"publish",
162+
"--catalog",
163+
catalog.to_str().unwrap(),
164+
"--spec",
165+
candidate.to_str().unwrap(),
166+
"--input-sha256",
167+
&input_sha256,
168+
"--expect-absent",
169+
]);
170+
unsafe {
171+
command.pre_exec(|| {
172+
libc::umask(0o077);
173+
Ok(())
174+
});
175+
}
176+
let published = command.output().unwrap();
177+
assert!(
178+
published.status.success(),
179+
"{}",
180+
String::from_utf8_lossy(&published.stderr)
181+
);
182+
assert_eq!(
183+
fs::metadata(target(&catalog)).unwrap().mode() & 0o7777,
184+
0o644
185+
);
186+
}
187+
188+
#[test]
189+
fn spec_replacement_preserves_the_accepted_target_mode() {
190+
let temp = tempfile::tempdir().unwrap();
191+
let catalog = temp.path().join("catalog");
192+
let agent = catalog.join("agents/host/worker");
193+
fs::create_dir_all(&agent).unwrap();
194+
let current = valid_spec(false);
195+
fs::write(agent.join("agent.kdl"), &current).unwrap();
196+
fs::set_permissions(agent.join("agent.kdl"), fs::Permissions::from_mode(0o640)).unwrap();
197+
let candidate = temp.path().join("candidate.kdl");
198+
fs::write(&candidate, valid_spec(true)).unwrap();
199+
let input_sha256 = sha256(&fs::read(&candidate).unwrap());
200+
201+
let mut command = st2();
202+
command.args([
203+
"agent",
204+
"publish",
205+
"--catalog",
206+
catalog.to_str().unwrap(),
207+
"--spec",
208+
candidate.to_str().unwrap(),
209+
"--input-sha256",
210+
&input_sha256,
211+
"--expect-sha256",
212+
&sha256(current.as_bytes()),
213+
]);
214+
unsafe {
215+
command.pre_exec(|| {
216+
libc::umask(0o077);
217+
Ok(())
218+
});
219+
}
220+
let published = command.output().unwrap();
221+
assert!(
222+
published.status.success(),
223+
"{}",
224+
String::from_utf8_lossy(&published.stderr)
225+
);
226+
assert_eq!(
227+
fs::metadata(target(&catalog)).unwrap().mode() & 0o7777,
228+
0o640
229+
);
230+
}
231+
116232
#[test]
117233
fn caller_source_digest_rejects_mutation_and_symlink_swaps_before_publication() {
118234
let temp = tempfile::tempdir().unwrap();

0 commit comments

Comments
 (0)