Skip to main content

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}