clawft_kernel/workload_runtime/
seed_creds.rs1use std::fs;
14use std::io::Write;
15use std::os::unix::fs::{DirBuilderExt, MetadataExt, OpenOptionsExt, PermissionsExt};
16use std::path::{Path, PathBuf};
17
18use clawft_types::runtime_paths::{RootSource, RuntimePaths};
19use clawft_types::secret::SecretString;
20
21use super::seed_http::SeedCredentials;
22use super::types::RuntimeError;
23use crate::workload_pkg::manifest::valid_token;
24
25pub const SEED_SECRETS_SUBDIR: &str = "secrets/workload.seed";
27const MAX_TOKEN: u64 = 512;
29
30#[derive(Debug, Clone)]
32pub struct FileCredentials {
33 dir: PathBuf,
34}
35
36fn io(what: &str, e: std::io::Error) -> RuntimeError {
37 RuntimeError::Backend(format!("seed credential {what}: {e}"))
38}
39
40fn operator_dir(paths: &RuntimePaths, home: Option<&Path>) -> (PathBuf, bool) {
42 let new = paths.root().join(SEED_SECRETS_SUBDIR);
43 if !new.exists()
44 && matches!(paths.source(), RootSource::Project(_))
45 && let Some(legacy) = home.map(|h| h.join(".clawft").join(SEED_SECRETS_SUBDIR))
46 && legacy.is_dir()
47 {
48 return (legacy, true);
49 }
50 (new, false)
51}
52
53impl FileCredentials {
54 pub fn new(dir: impl Into<PathBuf>) -> Self {
56 Self { dir: dir.into() }
57 }
58
59 pub fn operator_default() -> Result<Self, RuntimeError> {
66 let home = clawft_types::runtime_paths::home_dir();
67 let (dir, legacy) = operator_dir(&RuntimePaths::resolve(), home.as_deref());
68 if legacy {
69 tracing::warn!(
70 dir = %dir.display(),
71 "using the legacy ~/.clawft seed token store; it is not moved automatically"
72 );
73 }
74 Ok(Self::new(dir))
75 }
76
77 pub fn dir(&self) -> &Path {
79 &self.dir
80 }
81
82 fn path_for(&self, node_id: &str) -> Result<PathBuf, RuntimeError> {
83 if !valid_token(node_id, 64) || node_id.starts_with('.') {
84 return Err(RuntimeError::InvalidConfig(
85 "seed node id must be a plain token".into(),
86 ));
87 }
88 Ok(self.dir.join(format!("{node_id}.token")))
89 }
90
91 fn ensure_dir(&self) -> Result<(), RuntimeError> {
92 match fs::symlink_metadata(&self.dir) {
93 Ok(m) if m.file_type().is_symlink() || !m.is_dir() => Err(RuntimeError::InvalidConfig(
94 "seed secret dir is not a plain directory".into(),
95 )),
96 Ok(_) => fs::set_permissions(&self.dir, fs::Permissions::from_mode(0o700))
97 .map_err(|e| io("dir", e)),
98 Err(_) => fs::DirBuilder::new()
99 .recursive(true)
100 .mode(0o700)
101 .create(&self.dir)
102 .map_err(|e| io("dir", e)),
103 }
104 }
105}
106
107impl SeedCredentials for FileCredentials {
108 fn get(&self, node_id: &str) -> Result<SecretString, RuntimeError> {
109 let path = self.path_for(node_id)?;
110 let meta = fs::symlink_metadata(&path).map_err(|_| {
111 RuntimeError::InvalidConfig(format!("no credential for {node_id}; pair the Seed"))
112 })?;
113 if !meta.file_type().is_file() {
114 return Err(RuntimeError::InvalidConfig(
115 "seed credential is not a regular file".into(),
116 ));
117 }
118 if meta.mode() & 0o077 != 0 {
119 return Err(RuntimeError::InvalidConfig(
120 "seed credential is readable by other users; re-pair".into(),
121 ));
122 }
123 if meta.len() > MAX_TOKEN {
124 return Err(RuntimeError::InvalidConfig(
125 "seed credential too large".into(),
126 ));
127 }
128 let raw = fs::read_to_string(&path).map_err(|e| io("read", e))?;
129 let token = raw.trim();
130 if token.is_empty() || !token.bytes().all(|b| b.is_ascii_graphic()) {
131 return Err(RuntimeError::InvalidConfig(
132 "seed credential is malformed".into(),
133 ));
134 }
135 Ok(SecretString::new(token))
136 }
137
138 fn put(&self, node_id: &str, token: SecretString) -> Result<(), RuntimeError> {
139 let path = self.path_for(node_id)?;
140 let t = token.expose();
141 if t.is_empty() || t.len() as u64 > MAX_TOKEN || !t.bytes().all(|b| b.is_ascii_graphic()) {
142 return Err(RuntimeError::InvalidConfig(
143 "refusing a malformed token".into(),
144 ));
145 }
146 self.ensure_dir()?;
147 let tmp = self.dir.join(format!(".{node_id}.token.tmp"));
148 let _ = fs::remove_file(&tmp);
149 let mut f = fs::OpenOptions::new()
150 .create_new(true)
151 .write(true)
152 .mode(0o600)
153 .open(&tmp)
154 .map_err(|e| io("write", e))?;
155 f.write_all(t.as_bytes())
156 .and_then(|_| f.sync_all())
157 .map_err(|e| io("write", e))?;
158 fs::rename(&tmp, &path).map_err(|e| io("rename", e))
159 }
160}
161
162#[cfg(test)]
163mod operator_dir_tests {
164 use super::*;
165
166 #[test]
167 fn legacy_store_is_used_only_when_the_project_has_none() {
168 let t = tempfile::tempdir().unwrap();
169 let home = t.path().join("home");
170 fs::create_dir_all(home.join(".clawft").join(SEED_SECRETS_SUBDIR)).unwrap();
171 let proj = t.path().join("proj");
172 fs::create_dir_all(proj.join(".weftos")).unwrap();
173 fs::write(proj.join(".weftos/project.toml"), "").unwrap();
174 let paths = RuntimePaths::resolve_with(None, Some(&proj), Some(&home));
175
176 let (dir, legacy) = operator_dir(&paths, Some(&home));
177 assert!(legacy);
178 assert_eq!(dir, home.join(".clawft").join(SEED_SECRETS_SUBDIR));
179
180 fs::create_dir_all(proj.join(".weftos/runtime").join(SEED_SECRETS_SUBDIR)).unwrap();
181 let (dir, legacy) = operator_dir(&paths, Some(&home));
182 assert!(!legacy);
183 assert_eq!(dir, proj.join(".weftos/runtime").join(SEED_SECRETS_SUBDIR));
184
185 let iso = RuntimePaths::resolve_with(Some("/x"), Some(&proj), Some(&home));
187 assert!(!operator_dir(&iso, Some(&home)).1);
188 }
189}