1use std::fs::{File, OpenOptions};
5use std::io::{ErrorKind, Write};
6use std::path::{Path, PathBuf};
7use std::sync::OnceLock;
8
9use serde::{Deserialize, Serialize};
10
11use crate::engine::{valid_session_id, EnvSnapshot};
12use crate::error::{ErrorCode, ToolError};
13use crate::liveness::Sentinel;
14use crate::settings::{Settings, Source};
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum IdentitySource {
18 Env,
19 File,
20 None,
21}
22
23#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct Identity {
26 pub session_id: Option<String>,
27 pub directory: Option<PathBuf>,
28 pub server_url: Option<String>,
29 pub pair_id: Option<String>,
30 pub source: IdentitySource,
31}
32
33impl Identity {
34 fn unpaired() -> Self {
35 Identity {
36 session_id: None,
37 directory: None,
38 server_url: None,
39 pair_id: None,
40 source: IdentitySource::None,
41 }
42 }
43}
44
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47pub struct Record {
48 pub pair_id: String,
49 pub session_id: String,
50 pub directory: PathBuf,
51 #[serde(default)]
52 pub server_url: Option<String>,
53 pub created: i64,
54 #[serde(default)]
55 pub controller: Option<String>,
56 pub generation: u32,
57 pub tab: bool,
58}
59
60pub fn valid_pair_id(id: &str) -> bool {
62 id.len() == 12 && id.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
63}
64
65fn invalid_pair(id: &str) -> ToolError {
66 ToolError::new(
67 ErrorCode::InvalidArgument,
68 format!("'{}' is not a pair id", crate::compact::redact_text(id)),
69 )
70 .hint("pair ids match ^[0-9a-f]{12}$")
71}
72
73pub fn resolve_identity(env: &EnvSnapshot, home: &Path) -> (Identity, Vec<ToolError>) {
76 let mut warnings = Vec::new();
77 if let Some(p) = env.pair.as_deref() {
78 if !valid_pair_id(p) {
79 warnings.push(
80 invalid_pair(p).hint("OPENCODE_CTL_PAIR is ignored; this server runs unpaired"),
81 );
82 }
83 }
84 if let Some(s) = env.session.as_deref() {
85 if !valid_session_id(s) {
86 warnings.push(
87 ToolError::new(
88 ErrorCode::InvalidArgument,
89 format!(
90 "OPENCODE_CTL_SESSION '{}' is not a session id",
91 crate::compact::redact_text(s)
92 ),
93 )
94 .hint("session ids match ^ses_[A-Za-z0-9]{26}$; this server runs unpaired"),
95 );
96 }
97 }
98 if !warnings.is_empty() {
99 return (Identity::unpaired(), warnings);
100 }
101 if env.session.is_some() {
102 return (
103 Identity {
104 session_id: env.session.clone(),
105 directory: env.directory.clone(),
106 server_url: env.server.clone(),
107 pair_id: env.pair.clone(),
108 source: IdentitySource::Env,
109 },
110 warnings,
111 );
112 }
113 let Some(pair) = env.pair.clone() else {
114 let any = env.directory.is_some() || env.server.is_some();
115 return (
116 Identity {
117 session_id: None,
118 directory: env.directory.clone(),
119 server_url: env.server.clone(),
120 pair_id: None,
121 source: if any {
122 IdentitySource::Env
123 } else {
124 IdentitySource::None
125 },
126 },
127 warnings,
128 );
129 };
130 let fallback = Identity {
131 pair_id: Some(pair.clone()),
132 ..Identity::unpaired()
133 };
134 match read_record(home, &pair) {
135 Ok(Some(r)) if valid_session_id(&r.session_id) => (
136 Identity {
137 session_id: Some(r.session_id),
138 directory: env.directory.clone().or(Some(r.directory)),
139 server_url: env.server.clone().or(r.server_url),
140 pair_id: Some(pair),
141 source: IdentitySource::File,
142 },
143 warnings,
144 ),
145 Ok(_) => {
146 warnings.push(
147 ToolError::new(
148 ErrorCode::PairNotFound,
149 format!("no usable pair record for {pair}: running with the pair id only"),
150 )
151 .hint("restart the controller with `opencode-pair resume <pair-id>`"),
152 );
153 (fallback, warnings)
154 }
155 Err(e) => {
156 warnings.push(e);
157 (fallback, warnings)
158 }
159 }
160}
161
162fn has_userinfo(url: &str) -> bool {
163 let rest = url.split_once("://").map_or(url, |(_, r)| r);
164 rest.split('/').next().is_some_and(|a| a.contains('@'))
165}
166
167#[must_use = "the warnings must reach the startup log"]
174pub fn apply_identity(
175 env: &mut EnvSnapshot,
176 settings: &mut Settings,
177 id: &Identity,
178) -> Vec<ToolError> {
179 let env_server = env.server.clone();
180 env.session = id.session_id.clone();
181 env.directory = id.directory.clone();
182 env.server = id.server_url.clone();
183 env.pair = id.pair_id.clone();
184 if id.source != IdentitySource::File {
185 return Vec::new();
186 }
187 if let Some(url) = id.server_url.as_deref().filter(|u| has_userinfo(u)) {
188 let origin = if env_server.as_deref() == Some(url) {
189 "OPENCODE_CTL_SERVER"
190 } else {
191 "the pair record's server_url"
192 };
193 env.server = None;
194 return vec![ToolError::new(
195 ErrorCode::InvalidArgument,
196 format!(
197 "{origin} ({}) carries userinfo and is ignored",
198 crate::compact::redact_text(url)
199 ),
200 )
201 .hint("put credentials in OPENCODE_SERVER_PASSWORD, not in the URL")];
202 }
203 settings.server_url = id.server_url.clone();
204 settings.sources.insert("server_url", Source::Env);
205 Vec::new()
206}
207
208#[must_use = "the warnings must reach the startup log"]
212pub fn apply_record_server(
213 home: &Path,
214 pair_id: &str,
215 env: &mut EnvSnapshot,
216 settings: &mut Settings,
217) -> Vec<ToolError> {
218 let Ok(Some(record)) = read_record(home, pair_id) else {
219 return Vec::new();
220 };
221 let id = Identity {
222 session_id: Some(record.session_id),
223 directory: Some(record.directory),
224 server_url: record.server_url,
225 pair_id: Some(pair_id.to_string()),
226 source: IdentitySource::File,
227 };
228 apply_identity(env, settings, &id)
229}
230
231pub fn record_path(home: &Path, id: &str) -> PathBuf {
234 home.join(format!("pair.{id}.json"))
235}
236
237fn lock_path(home: &Path, id: &str) -> PathBuf {
238 home.join(format!("pair.{id}.lock"))
239}
240
241fn ensure_dir(dir: &Path) -> Result<(), ToolError> {
242 #[cfg(unix)]
243 {
244 use std::os::unix::fs::DirBuilderExt;
245 std::fs::DirBuilder::new()
246 .recursive(true)
247 .mode(0o700)
248 .create(dir)?;
249 }
250 #[cfg(not(unix))]
251 std::fs::create_dir_all(dir)?;
252 Ok(())
253}
254
255fn open_new(path: &Path, mode: u32) -> std::io::Result<File> {
256 let mut o = OpenOptions::new();
257 o.write(true).create_new(true);
258 #[cfg(unix)]
259 {
260 use std::os::unix::fs::OpenOptionsExt;
261 o.mode(mode);
262 }
263 #[cfg(not(unix))]
264 let _ = mode;
265 o.open(path)
266}
267
268fn temp_sibling(path: &Path) -> PathBuf {
269 let mut name = path.file_name().unwrap_or_default().to_os_string();
270 name.push(format!(".tmp-{}", uuid::Uuid::new_v4()));
271 path.with_file_name(name)
272}
273
274fn write_temp(path: &Path, content: &[u8], mode: u32) -> Result<PathBuf, ToolError> {
275 let tmp = temp_sibling(path);
276 let mut f = open_new(&tmp, mode)?;
277 if let Err(e) = f.write_all(content).and_then(|()| f.flush()) {
278 drop(f);
279 let _ = std::fs::remove_file(&tmp);
280 return Err(e.into());
281 }
282 Ok(tmp)
283}
284
285pub fn claim_file(path: &Path, content: &[u8], mode: u32) -> Result<bool, ToolError> {
288 if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty()) {
289 ensure_dir(dir)?;
290 }
291 let tmp = write_temp(path, content, mode)?;
292 let linked = std::fs::hard_link(&tmp, path);
293 let _ = std::fs::remove_file(&tmp);
294 match linked {
295 Ok(()) => Ok(true),
296 Err(e) if e.kind() == ErrorKind::AlreadyExists => Ok(false),
297 Err(e)
298 if matches!(
299 e.kind(),
300 ErrorKind::Unsupported | ErrorKind::PermissionDenied | ErrorKind::CrossesDevices
301 ) =>
302 {
303 static LOGGED: OnceLock<()> = OnceLock::new();
304 LOGGED.get_or_init(|| tracing::warn!("claim fallback: {:?}", e.kind()));
305 match open_new(path, mode) {
306 Ok(mut f) => {
307 f.write_all(content).and_then(|()| f.flush())?;
308 Ok(true)
309 }
310 Err(e) if e.kind() == ErrorKind::AlreadyExists => Ok(false),
311 Err(e) => Err(e.into()),
312 }
313 }
314 Err(e) => Err(e.into()),
315 }
316}
317
318fn replace_file(path: &Path, content: &[u8], mode: u32) -> Result<(), ToolError> {
320 let tmp = write_temp(path, content, mode)?;
321 std::fs::rename(&tmp, path).map_err(|e| {
322 let _ = std::fs::remove_file(&tmp);
323 ToolError::from(e)
324 })
325}
326
327pub fn write_record_new(home: &Path, record: &Record) -> Result<(), ToolError> {
328 if !valid_pair_id(&record.pair_id) {
329 return Err(invalid_pair(&record.pair_id));
330 }
331 let body = serde_json::to_vec_pretty(record)
332 .map_err(|e| ToolError::new(ErrorCode::IoError, e.to_string()))?;
333 if claim_file(&record_path(home, &record.pair_id), &body, 0o600)? {
334 Ok(())
335 } else {
336 Err(ToolError::new(
337 ErrorCode::InvalidArgument,
338 format!("pair record {} already exists", record.pair_id),
339 ))
340 }
341}
342
343fn parse_record(path: &Path, raw: &str) -> Option<Record> {
344 match serde_json::from_str::<Record>(raw) {
345 Ok(r) => Some(r),
346 Err(e) => {
347 tracing::warn!("unparsable pair record {}: {e}", path.display());
348 None
349 }
350 }
351}
352
353pub fn read_record(home: &Path, id: &str) -> Result<Option<Record>, ToolError> {
355 if !valid_pair_id(id) {
356 return Err(invalid_pair(id));
357 }
358 let path = record_path(home, id);
359 match std::fs::read_to_string(&path) {
360 Ok(raw) => Ok(parse_record(&path, &raw).map(|mut r| {
361 r.pair_id = id.to_string();
363 r
364 })),
365 Err(e) if e.kind() == ErrorKind::NotFound => Ok(None),
366 Err(e) => Err(ToolError::new(
367 ErrorCode::IoError,
368 format!("pair record unreadable: {e}"),
369 )),
370 }
371}
372
373pub fn record_ids(home: &Path) -> Vec<String> {
375 let Ok(entries) = std::fs::read_dir(home) else {
376 return Vec::new();
377 };
378 let mut ids: Vec<String> = entries
379 .flatten()
380 .filter_map(|e| {
381 let name = e.file_name().into_string().ok()?;
382 let id = name.strip_prefix("pair.")?.strip_suffix(".json")?;
383 valid_pair_id(id).then(|| id.to_string())
384 })
385 .collect();
386 ids.sort();
387 ids
388}
389
390pub fn list_records(home: &Path) -> Vec<Record> {
393 record_ids(home)
394 .into_iter()
395 .filter_map(|id| read_record(home, &id).ok().flatten())
396 .collect()
397}
398
399pub fn rewrite_record(
401 home: &Path,
402 id: &str,
403 generation: u32,
404 tab: bool,
405 after_lock: Option<&dyn Fn()>,
406) -> Result<(), ToolError> {
407 if !valid_pair_id(id) {
408 return Err(invalid_pair(id));
409 }
410 ensure_dir(home)?;
411 let mut o = OpenOptions::new();
412 o.write(true).create(true).truncate(false);
413 #[cfg(unix)]
414 {
415 use std::os::unix::fs::OpenOptionsExt;
416 o.mode(0o600);
417 }
418 let lock = o.open(lock_path(home, id))?;
419 lock.lock()?;
420 if let Some(f) = after_lock {
421 f();
422 }
423 let mut record = read_record(home, id)?.ok_or_else(|| {
424 ToolError::new(ErrorCode::PairNotFound, format!("no pair record for {id}"))
425 })?;
426 if generation < record.generation {
427 tracing::warn!(
428 "rewrite_record {id}: generation {generation} is below the stored {}; ignored",
429 record.generation
430 );
431 return Ok(());
432 }
433 record.generation = generation;
434 record.tab = tab;
435 let body = serde_json::to_vec_pretty(&record)
436 .map_err(|e| ToolError::new(ErrorCode::IoError, e.to_string()))?;
437 replace_file(&record_path(home, id), &body, 0o600)
438 }
440
441pub fn delete_pair_files(home: &Path, id: &str) -> Result<(), ToolError> {
443 if !valid_pair_id(id) {
444 return Err(invalid_pair(id));
445 }
446 let prefix = format!("pair.{id}.");
447 let Ok(entries) = std::fs::read_dir(home) else {
448 return Ok(());
449 };
450 for entry in entries.flatten() {
451 if entry.file_name().to_string_lossy().starts_with(&prefix) {
452 match std::fs::remove_file(entry.path()) {
453 Ok(()) => {}
454 Err(e) if e.kind() == ErrorKind::NotFound => {}
455 Err(e) => return Err(e.into()),
456 }
457 }
458 }
459 Ok(())
460}
461
462pub fn write_sentinel(path: &Path, s: &Sentinel) -> Result<(), ToolError> {
463 if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty()) {
464 ensure_dir(dir)?;
465 }
466 replace_file(path, s.to_string().as_bytes(), 0o600)
467}
468
469pub fn read_sentinel(path: &Path) -> Option<Sentinel> {
471 Sentinel::parse(&std::fs::read_to_string(path).ok()?)
472}