Skip to main content

joy_core/
security_md.rs

1// Copyright (c) 2026 Joydev GmbH (joydev.com)
2// SPDX-License-Identifier: MIT
3
4//! Render and update the project's SECURITY.md.
5//!
6//! Joy ships a SECURITY.md template that documents the public-by-design
7//! auth schema fields (`verify_key`, `kdf_nonce`, `enrollment_verifier`,
8//! `delegation_verifier`) so SOC analysts and secret scanners have a
9//! canonical explanation when keyword-based detectors flag those names.
10//! Per ADR-035 the template is rendered to the project root, not to
11//! `.joy/`, so GitHub and similar forges show it in their Security
12//! policy tab.
13//!
14//! The Joy block is delimited by `<!-- joy:security begin -->` and
15//! `<!-- joy:security end -->`. Content outside the markers is
16//! preserved across rendering.
17
18use std::path::Path;
19
20use crate::error::JoyError;
21
22const SECURITY_TEMPLATE: &str = include_str!("../templates/SECURITY.md");
23const BLOCK_START: &str = "<!-- joy:security begin -->";
24const BLOCK_END: &str = "<!-- joy:security end -->";
25
26/// Return the body that the Joy block should contain.
27///
28/// Currently the template is fully static; rendering may take parameters
29/// in the future (project name, member emails) without changing this
30/// signature - callers should not assume the template is static.
31pub fn rendered_body() -> &'static str {
32    SECURITY_TEMPLATE
33}
34
35/// Render SECURITY.md at `path`, preserving any existing user content
36/// outside the Joy markers. Returns `true` if the file was created or
37/// updated, `false` if it was already current.
38pub fn render(path: &Path) -> Result<bool, JoyError> {
39    let body = rendered_body();
40    let block = format!("{BLOCK_START}\n{body}{BLOCK_END}\n");
41
42    let new_content = if path.is_file() {
43        let existing = std::fs::read_to_string(path).map_err(|e| JoyError::ReadFile {
44            path: path.to_path_buf(),
45            source: e,
46        })?;
47        merge_block(&existing, &block)
48    } else {
49        block.clone()
50    };
51
52    if path.is_file() {
53        let existing = std::fs::read_to_string(path).map_err(|e| JoyError::ReadFile {
54            path: path.to_path_buf(),
55            source: e,
56        })?;
57        if existing == new_content {
58            return Ok(false);
59        }
60    }
61
62    std::fs::write(path, new_content).map_err(|e| JoyError::WriteFile {
63        path: path.to_path_buf(),
64        source: e,
65    })?;
66    Ok(true)
67}
68
69/// Inspect `path` and report whether `render` would change anything.
70pub fn is_current(path: &Path) -> Result<bool, JoyError> {
71    if !path.is_file() {
72        return Ok(false);
73    }
74    let body = rendered_body();
75    let block = format!("{BLOCK_START}\n{body}{BLOCK_END}\n");
76    let existing = std::fs::read_to_string(path).map_err(|e| JoyError::ReadFile {
77        path: path.to_path_buf(),
78        source: e,
79    })?;
80    Ok(existing == merge_block(&existing, &block))
81}
82
83fn merge_block(existing: &str, block: &str) -> String {
84    if let (Some(start), Some(end_pos)) = (existing.find(BLOCK_START), existing.find(BLOCK_END)) {
85        let end = end_pos + BLOCK_END.len();
86        let mut out = String::new();
87        out.push_str(&existing[..start]);
88        out.push_str(block.trim_end());
89        // Preserve a single newline before any user content that follows.
90        let tail = &existing[end..];
91        if !tail.is_empty() {
92            out.push('\n');
93            out.push_str(tail.trim_start_matches('\n'));
94        } else {
95            out.push('\n');
96        }
97        out
98    } else {
99        // No existing Joy block: append after a blank line.
100        let trimmed = existing.trim_end();
101        if trimmed.is_empty() {
102            block.to_string()
103        } else {
104            format!("{trimmed}\n\n{block}")
105        }
106    }
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112    use std::fs;
113    use tempfile::tempdir;
114
115    #[test]
116    fn render_creates_file_when_missing() {
117        let dir = tempdir().unwrap();
118        let path = dir.path().join("SECURITY.md");
119        let changed = render(&path).unwrap();
120        assert!(changed);
121        let content = fs::read_to_string(&path).unwrap();
122        assert!(content.contains(BLOCK_START));
123        assert!(content.contains(BLOCK_END));
124        assert!(content.contains("verify_key"));
125    }
126
127    #[test]
128    fn render_is_idempotent() {
129        let dir = tempdir().unwrap();
130        let path = dir.path().join("SECURITY.md");
131        render(&path).unwrap();
132        let changed = render(&path).unwrap();
133        assert!(!changed);
134    }
135
136    #[test]
137    fn render_preserves_user_content_outside_block() {
138        let dir = tempdir().unwrap();
139        let path = dir.path().join("SECURITY.md");
140        let user_content = "# My SECURITY policy\n\nUser-authored intro.\n\n";
141        fs::write(&path, user_content).unwrap();
142        render(&path).unwrap();
143        let content = fs::read_to_string(&path).unwrap();
144        assert!(content.starts_with("# My SECURITY policy"));
145        assert!(content.contains("User-authored intro."));
146        assert!(content.contains(BLOCK_START));
147    }
148
149    #[test]
150    fn render_updates_existing_block_in_place() {
151        let dir = tempdir().unwrap();
152        let path = dir.path().join("SECURITY.md");
153        // Existing file with a stale Joy block surrounded by user content.
154        let stale =
155            format!("# Title\n\n{BLOCK_START}\nold content\n{BLOCK_END}\n\nFooter content.\n",);
156        fs::write(&path, &stale).unwrap();
157        render(&path).unwrap();
158        let content = fs::read_to_string(&path).unwrap();
159        assert!(content.starts_with("# Title"));
160        assert!(content.contains("Footer content."));
161        assert!(!content.contains("old content"));
162        assert!(content.contains("verify_key"));
163    }
164
165    #[test]
166    fn is_current_reports_false_for_missing_file() {
167        let dir = tempdir().unwrap();
168        let path = dir.path().join("SECURITY.md");
169        assert!(!is_current(&path).unwrap());
170    }
171
172    #[test]
173    fn is_current_reports_true_after_render() {
174        let dir = tempdir().unwrap();
175        let path = dir.path().join("SECURITY.md");
176        render(&path).unwrap();
177        assert!(is_current(&path).unwrap());
178    }
179}