stellar_agent_toolsets_install/pin.rs
1//! Pinned install record for toolsets.
2//!
3//! Each installed toolset has a corresponding pin record stored at
4//! `<toolsets_root>/<package>/.stellar-agent-toolset-pin.json`. The record
5//! stores the **package name** (not a deletable path) plus the version,
6//! shasum, publisher public key (G-strkey), and install timestamp.
7//!
8//! ## Atomic write (temp+rename, same-FS)
9//!
10//! Pin records are written atomically: the content is written to a temporary
11//! file inside the toolsets root (same filesystem as the final location), then
12//! renamed over the target path. If the rename fails, the temporary file is
13//! removed. If a pin write fails after the toolset directory has been moved,
14//! the toolset directory is rolled back (removed).
15
16use std::io::Write;
17use std::path::{Path, PathBuf};
18
19use serde::{Deserialize, Serialize};
20use stellar_agent_toolsets::CapabilitySet;
21
22use crate::{ToolsetInstallError, validate_package_name};
23
24/// File name for the pin record inside the toolset package directory.
25pub(crate) const PIN_FILE_NAME: &str = ".stellar-agent-toolset-pin.json";
26
27/// Pinned install record for a toolset.
28///
29/// Stored as `<toolsets_root>/<package>/.stellar-agent-toolset-pin.json`.
30///
31/// ## Capability fields
32///
33/// `capabilities` and `allowed_tools` are persisted at install time from the
34/// signature-verified `TOOLSET.md` parse output. Dispatch reads these fields
35/// from the pin rather than re-parsing on-disk `TOOLSET.md` (TOCTOU avoidance).
36///
37/// ### Legacy pin behaviour
38///
39/// Both fields use `#[serde(default)]`: a pin record written before this
40/// extension will deserialise with `capabilities = CapabilitySet::empty()`
41/// and `allowed_tools = vec![]`. An empty capabilities set means the runtime
42/// grants no capability, so every key-touching action is refused (fail-closed)
43/// until the toolset is reinstalled with a current binary.
44///
45/// See also: `tests::legacy_pin_without_capabilities_fails_closed`.
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47#[non_exhaustive]
48pub struct ToolsetPinRecord {
49 /// Package name (`[a-z0-9-]`).
50 ///
51 /// This is the VALIDATED name, not a stored path — uninstall
52 /// reconstructs the path from this name.
53 pub package: String,
54
55 /// Installed version (SemVer string).
56 pub version: String,
57
58 /// Lowercase hex SHA-256 of the installed package bytes (64 chars).
59 pub shasum: String,
60
61 /// Publisher public key as a Stellar G-strkey.
62 ///
63 /// Not redacted in the pin record (it is stored, not logged); only
64 /// redacted in `Display`/log output.
65 pub publisher: String,
66
67 /// ISO-8601 install timestamp (UTC).
68 pub installed_at: String,
69
70 /// Capabilities declared by the toolset manifest, persisted at install time.
71 ///
72 /// Sourced from the signature-verified `TOOLSET.md` parse.
73 /// Default: [`CapabilitySet::empty()`] — a legacy pin with no `capabilities`
74 /// field deserialises to an empty set, which **refuses every action** until
75 /// reinstall (fail-closed).
76 ///
77 /// ## Integrity provenance
78 ///
79 /// The capabilities INHERIT install-time provenance (parsed from the
80 /// shasum-verified, publisher-signed package) but, once COPIED into the
81 /// locally-written unsigned pin JSON, are thereafter protected only by the
82 /// toolsets-dir trust boundary — NOT by the shasum (no over-claim).
83 /// The signing isolation (no toolset can EVER reach a signing/key tool) is
84 /// STRUCTURAL and tamper-proof regardless of pin contents.
85 #[serde(default)]
86 pub capabilities: CapabilitySet,
87
88 /// Intersective `allowed_tools` from the toolset manifest, persisted at install time.
89 ///
90 /// When non-empty, narrows the capability grant: only tools present in
91 /// BOTH the capability matrix AND this list are reachable. An empty list
92 /// means no narrowing (the full capability grant applies).
93 ///
94 /// Default: `vec![]` — legacy pins with no `allowed_tools` field
95 /// deserialise to an empty list (no narrowing).
96 #[serde(default)]
97 pub allowed_tools: Vec<String>,
98
99 /// SHA-256 hex digest (64 lowercase hex chars) of the `TOOLSET.md` bytes as
100 /// extracted at install time.
101 ///
102 /// At every dispatch, the toolsets runtime re-reads the on-disk `TOOLSET.md`,
103 /// recomputes SHA-256, and compares against this field. A mismatch refuses
104 /// the dispatch.
105 ///
106 /// ## Legacy behaviour
107 ///
108 /// `#[serde(default)]` → `None` for pins written before this field was added.
109 /// Legacy pins skip the re-verification step at dispatch (no `toolset_md_shasum`
110 /// to compare against). This is intentionally OPEN for legacy pins — the
111 /// capability-source invariant (runtime reads capabilities from the pin, not
112 /// from the on-disk `TOOLSET.md`) ensures that a tampered `TOOLSET.md` cannot
113 /// escalate capabilities even without the digest check. Reinstalling with a
114 /// current binary populates the field and activates the check.
115 ///
116 /// ## Security scope
117 ///
118 /// The `TOOLSET.md` digest covers post-install tamper detection of the toolset's
119 /// human-readable manifest. It does NOT re-verify the full package hash
120 /// (`pin.shasum` is SHA-256 of the original `.tar.gz` tarball, which is not
121 /// retained post-extraction). The capability-escalation attack path is
122 /// already closed by reading capabilities from the pin rather than re-parsing
123 /// the on-disk file; this check adds tamper-evidence for the manifest text.
124 #[serde(default)]
125 pub toolset_md_shasum: Option<String>,
126}
127
128impl ToolsetPinRecord {
129 /// Returns the path for this pin record relative to `toolsets_root`.
130 ///
131 /// Path: `<toolsets_root>/<package>/<PIN_FILE_NAME>`.
132 #[must_use]
133 pub fn pin_path(&self, toolsets_root: &Path) -> PathBuf {
134 toolsets_root.join(&self.package).join(PIN_FILE_NAME)
135 }
136
137 /// Constructs a `ToolsetPinRecord` for use in tests and integration fixtures.
138 ///
139 /// This constructor exists because `ToolsetPinRecord` is `#[non_exhaustive]`
140 /// — struct expressions are only legal within the defining crate. External
141 /// test code (e.g. sibling runtime-layer unit tests) needs a way to build a
142 /// pin without going through the full install pipeline.
143 ///
144 /// Only available under `#[cfg(any(test, feature = "test-helpers"))]` — not
145 /// reachable from production binaries.
146 ///
147 /// # Examples
148 ///
149 /// ```rust,ignore
150 /// // Available in test builds or with the "test-helpers" feature.
151 /// use stellar_agent_toolsets::CapabilitySet;
152 /// use stellar_agent_toolsets_install::ToolsetPinRecord;
153 ///
154 /// let pin = ToolsetPinRecord::build_for_test(
155 /// "my-toolset",
156 /// "1.0.0",
157 /// &"a".repeat(64),
158 /// "GABC...",
159 /// "2026-06-12T00:00:00Z",
160 /// CapabilitySet::empty(),
161 /// vec![],
162 /// None,
163 /// );
164 /// ```
165 #[cfg(any(test, feature = "test-helpers"))]
166 #[must_use]
167 #[allow(clippy::too_many_arguments)]
168 pub fn build_for_test(
169 package: impl Into<String>,
170 version: impl Into<String>,
171 shasum: impl Into<String>,
172 publisher: impl Into<String>,
173 installed_at: impl Into<String>,
174 capabilities: stellar_agent_toolsets::CapabilitySet,
175 allowed_tools: Vec<String>,
176 toolset_md_shasum: Option<String>,
177 ) -> Self {
178 Self {
179 package: package.into(),
180 version: version.into(),
181 shasum: shasum.into(),
182 publisher: publisher.into(),
183 installed_at: installed_at.into(),
184 capabilities,
185 allowed_tools,
186 toolset_md_shasum,
187 }
188 }
189}
190
191/// Writes a pin record atomically (temp+rename, same filesystem).
192///
193/// Steps:
194/// 1. Serialise `record` to JSON.
195/// 2. Write to a temp file inside `toolsets_root` (same FS → rename is atomic).
196/// 3. Rename temp → `record.pin_path(toolsets_root)`.
197///
198/// If the rename fails, the temp file is removed and the error is returned.
199///
200/// # Errors
201///
202/// - [`ToolsetInstallError::Io`] — serialisation, write, or rename fails.
203pub(crate) fn write_pin_atomic(
204 record: &ToolsetPinRecord,
205 toolsets_root: &Path,
206) -> Result<(), ToolsetInstallError> {
207 let json = serde_json::to_string_pretty(record)
208 .map_err(|e| ToolsetInstallError::from_io(std::io::Error::other(e.to_string())))?;
209
210 let target_path = record.pin_path(toolsets_root);
211
212 // Write to a temp file inside toolsets_root (same FS).
213 let tmp =
214 tempfile::NamedTempFile::new_in(toolsets_root).map_err(ToolsetInstallError::from_io)?;
215 {
216 let mut writer = std::io::BufWriter::new(tmp.as_file());
217 writer
218 .write_all(json.as_bytes())
219 .map_err(ToolsetInstallError::from_io)?;
220 writer.flush().map_err(ToolsetInstallError::from_io)?;
221 }
222
223 // Atomic rename.
224 tmp.persist(&target_path).map_err(|e| {
225 // persist returns a PersistError; extract the io::Error.
226 ToolsetInstallError::from_io(e.error)
227 })?;
228
229 Ok(())
230}
231
232/// Reads and parses the pin record for `package` from `toolsets_root`.
233///
234/// Validates `package` against the `[a-z0-9-]` charset BEFORE constructing
235/// any filesystem path: a `package` value containing `/`, `\`, `.`, `..`, or
236/// any character outside `[a-z0-9-]` is rejected immediately with
237/// [`ToolsetInstallError::InvalidPackageName`] and produces NO filesystem path
238/// join.
239///
240/// Returns `None` if the pin file does not exist (toolset not installed).
241///
242/// # Errors
243///
244/// - [`ToolsetInstallError::InvalidPackageName`] — `package` fails the
245/// `[a-z0-9-]` charset validation.
246/// - [`ToolsetInstallError::PinRecordMalformed`] — pin file exists but cannot
247/// be parsed or contains an invalid package name.
248/// - [`ToolsetInstallError::Io`] — unexpected I/O error (not NotFound).
249pub fn read_pin(
250 package: &str,
251 toolsets_root: &Path,
252) -> Result<Option<ToolsetPinRecord>, ToolsetInstallError> {
253 // Validate before ANY path join.
254 validate_package_name(package)
255 .map_err(|detail| ToolsetInstallError::InvalidPackageName { detail })?;
256
257 let pin_path = toolsets_root.join(package).join(PIN_FILE_NAME);
258
259 let content = match std::fs::read_to_string(&pin_path) {
260 Ok(s) => s,
261 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
262 Err(e) => return Err(ToolsetInstallError::from_io(e)),
263 };
264
265 let record: ToolsetPinRecord =
266 serde_json::from_str(&content).map_err(|_| ToolsetInstallError::PinRecordMalformed {
267 detail: format!("pin record for '{package}' is not valid JSON"),
268 })?;
269
270 // Validate the stored package name.
271 if let Err(reason) = validate_package_name(&record.package) {
272 return Err(ToolsetInstallError::PinRecordMalformed {
273 detail: format!(
274 "pin record package name '{}' is invalid: {reason}",
275 stellar_agent_toolsets::sanitise_display(&record.package, 64),
276 ),
277 });
278 }
279
280 Ok(Some(record))
281}
282
283/// Removes the pin record for `package` from `toolsets_root`.
284///
285/// Returns `Ok(())` if the file was removed successfully or did not exist.
286///
287/// # Errors
288///
289/// - [`ToolsetInstallError::Io`] — unexpected removal error.
290pub(crate) fn remove_pin(package: &str, toolsets_root: &Path) -> Result<(), ToolsetInstallError> {
291 let pin_path = toolsets_root.join(package).join(PIN_FILE_NAME);
292 match std::fs::remove_file(&pin_path) {
293 Ok(()) => Ok(()),
294 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
295 Err(e) => Err(ToolsetInstallError::from_io(e)),
296 }
297}
298
299#[cfg(test)]
300mod tests {
301 #![allow(
302 clippy::unwrap_used,
303 clippy::expect_used,
304 reason = "test-only; panics acceptable in unit tests"
305 )]
306
307 use tempfile::TempDir;
308
309 use super::*;
310
311 fn test_record() -> ToolsetPinRecord {
312 ToolsetPinRecord {
313 package: "my-toolset".to_owned(),
314 version: "1.0.0".to_owned(),
315 shasum: "a".repeat(64),
316 publisher: "GABC...XYZ".to_owned(),
317 installed_at: "2026-06-01T00:00:00Z".to_owned(),
318 capabilities: stellar_agent_toolsets::CapabilitySet::empty(),
319 allowed_tools: vec![],
320 toolset_md_shasum: None,
321 }
322 }
323
324 #[test]
325 fn write_and_read_pin_roundtrip() {
326 let dir = TempDir::new().unwrap();
327 let toolsets_root = dir.path();
328
329 // Create the package directory (normally done by extraction).
330 let pkg_dir = toolsets_root.join("my-toolset");
331 std::fs::create_dir_all(&pkg_dir).unwrap();
332
333 let record = test_record();
334 write_pin_atomic(&record, toolsets_root).unwrap();
335
336 let loaded = read_pin("my-toolset", toolsets_root).unwrap().unwrap();
337 assert_eq!(loaded, record);
338 }
339
340 #[test]
341 fn missing_pin_returns_none() {
342 let dir = TempDir::new().unwrap();
343 let result = read_pin("nonexistent", dir.path()).unwrap();
344 assert!(result.is_none());
345 }
346
347 #[test]
348 fn malformed_pin_returns_error() {
349 let dir = TempDir::new().unwrap();
350 let toolsets_root = dir.path();
351 let pkg_dir = toolsets_root.join("bad-toolset");
352 std::fs::create_dir_all(&pkg_dir).unwrap();
353 let pin_path = pkg_dir.join(PIN_FILE_NAME);
354 std::fs::write(&pin_path, b"not json").unwrap();
355
356 let err = read_pin("bad-toolset", toolsets_root).unwrap_err();
357 assert!(
358 matches!(err, ToolsetInstallError::PinRecordMalformed { .. }),
359 "expected PinRecordMalformed, got: {err:?}"
360 );
361 }
362
363 #[test]
364 fn remove_pin_nonexistent_is_ok() {
365 let dir = TempDir::new().unwrap();
366 remove_pin("nonexistent", dir.path()).unwrap();
367 }
368
369 // A pin record written before the `capabilities` / `allowed_tools` fields
370 // were added MUST deserialise with `capabilities = CapabilitySet::empty()`
371 // and `allowed_tools = vec![]` — producing fail-closed behaviour at dispatch
372 // time (every action refused until reinstall).
373 #[test]
374 fn legacy_pin_without_capabilities_fails_closed() {
375 let dir = TempDir::new().unwrap();
376 let toolsets_root = dir.path();
377 let pkg_dir = toolsets_root.join("legacy-toolset");
378 std::fs::create_dir_all(&pkg_dir).unwrap();
379 let pin_path = pkg_dir.join(PIN_FILE_NAME);
380
381 // Write a legacy pin (no capabilities / allowed_tools fields).
382 let legacy_json = r#"{
383 "package": "legacy-toolset",
384 "version": "1.0.0",
385 "shasum": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
386 "publisher": "GABC...XYZ",
387 "installed_at": "2026-06-01T00:00:00Z"
388 }"#;
389 std::fs::write(&pin_path, legacy_json).unwrap();
390
391 let pin = read_pin("legacy-toolset", toolsets_root).unwrap().unwrap();
392
393 // capabilities must default to empty (fail-closed).
394 assert!(
395 pin.capabilities.is_empty(),
396 "legacy pin must deserialise with empty capabilities (fail-closed)"
397 );
398 // allowed_tools must default to empty.
399 assert!(
400 pin.allowed_tools.is_empty(),
401 "legacy pin must deserialise with empty allowed_tools"
402 );
403 }
404
405 // Verify that read_pin rejects attacker-controlled package names containing
406 // path traversal sequences BEFORE constructing any filesystem path.
407 #[test]
408 fn read_pin_dotdot_slash_rejected() {
409 let dir = TempDir::new().unwrap();
410 let err = read_pin("../foo", dir.path()).unwrap_err();
411 assert!(
412 matches!(err, ToolsetInstallError::InvalidPackageName { .. }),
413 "expected InvalidPackageName for '../foo', got: {err:?}"
414 );
415 }
416
417 #[test]
418 fn read_pin_slash_in_name_rejected() {
419 let dir = TempDir::new().unwrap();
420 let err = read_pin("a/b", dir.path()).unwrap_err();
421 assert!(
422 matches!(err, ToolsetInstallError::InvalidPackageName { .. }),
423 "expected InvalidPackageName for 'a/b', got: {err:?}"
424 );
425 }
426
427 #[test]
428 fn read_pin_backslash_in_name_rejected() {
429 let dir = TempDir::new().unwrap();
430 let err = read_pin("..\\foo", dir.path()).unwrap_err();
431 assert!(
432 matches!(err, ToolsetInstallError::InvalidPackageName { .. }),
433 "expected InvalidPackageName for '..\\\\foo', got: {err:?}"
434 );
435 }
436
437 // `build_for_test` is only compiled in test mode; exercise all arguments.
438 #[test]
439 fn build_for_test_constructs_matching_record() {
440 use stellar_agent_toolsets::parse_capability_value_pub;
441
442 let caps = parse_capability_value_pub("sign-payment").unwrap();
443 let record = ToolsetPinRecord::build_for_test(
444 "test-toolset",
445 "1.2.3",
446 "a".repeat(64),
447 "GABC...XYZ",
448 "2026-06-22T00:00:00Z",
449 caps.clone(),
450 vec!["stellar_pay".to_owned()],
451 Some("dead".repeat(16)),
452 );
453
454 assert_eq!(record.package, "test-toolset");
455 assert_eq!(record.version, "1.2.3");
456 assert_eq!(record.shasum, "a".repeat(64));
457 assert_eq!(record.publisher, "GABC...XYZ");
458 assert_eq!(record.installed_at, "2026-06-22T00:00:00Z");
459 assert_eq!(record.capabilities, caps);
460 assert_eq!(record.allowed_tools, vec!["stellar_pay"]);
461 assert_eq!(record.toolset_md_shasum, Some("dead".repeat(16)));
462 }
463
464 // Pin records where the stored `package` name fails validate_package_name
465 // must be detected during read_pin and returned as PinRecordMalformed.
466 #[test]
467 fn read_pin_rejects_invalid_stored_package_name_in_json() {
468 let dir = TempDir::new().unwrap();
469 let toolsets_root = dir.path();
470 // Create a package directory with a valid name.
471 let pkg_dir = toolsets_root.join("good-name");
472 std::fs::create_dir_all(&pkg_dir).unwrap();
473
474 // Write a pin JSON where the inner `package` field has an invalid value.
475 let invalid_pin_json = r#"{
476 "package": "BADNAME!@#",
477 "version": "1.0.0",
478 "shasum": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
479 "publisher": "GABC...XYZ",
480 "installed_at": "2026-06-01T00:00:00Z"
481 }"#;
482 std::fs::write(pkg_dir.join(PIN_FILE_NAME), invalid_pin_json).unwrap();
483
484 let err = read_pin("good-name", toolsets_root).unwrap_err();
485 assert!(
486 matches!(err, crate::ToolsetInstallError::PinRecordMalformed { .. }),
487 "expected PinRecordMalformed for invalid stored name, got: {err:?}"
488 );
489 }
490
491 #[test]
492 fn pin_with_capabilities_roundtrip() {
493 let dir = TempDir::new().unwrap();
494 let toolsets_root = dir.path();
495 let pkg_dir = toolsets_root.join("rich-toolset");
496 std::fs::create_dir_all(&pkg_dir).unwrap();
497
498 // Build a pin with capabilities.
499 let caps =
500 stellar_agent_toolsets::parse_capability_value_pub("read-balance propose-transaction")
501 .unwrap();
502 let record = ToolsetPinRecord {
503 package: "rich-toolset".to_owned(),
504 version: "2.0.0".to_owned(),
505 shasum: "b".repeat(64),
506 publisher: "GDEF...UVW".to_owned(),
507 installed_at: "2026-06-01T12:00:00Z".to_owned(),
508 capabilities: caps,
509 allowed_tools: vec!["stellar_balances".to_owned(), "stellar_pay".to_owned()],
510 toolset_md_shasum: None,
511 };
512
513 write_pin_atomic(&record, toolsets_root).unwrap();
514 let loaded = read_pin("rich-toolset", toolsets_root).unwrap().unwrap();
515
516 assert!(
517 loaded
518 .capabilities
519 .contains(stellar_agent_toolsets::Capability::ReadBalance)
520 );
521 assert!(
522 loaded
523 .capabilities
524 .contains(stellar_agent_toolsets::Capability::ProposeTransaction)
525 );
526 assert!(
527 !loaded
528 .capabilities
529 .contains(stellar_agent_toolsets::Capability::SuggestDestination)
530 );
531 assert_eq!(
532 loaded.allowed_tools,
533 vec!["stellar_balances", "stellar_pay"]
534 );
535 }
536}