1use std::collections::BTreeMap;
22use std::path::{Path, PathBuf};
23
24use serde::{Deserialize, Serialize};
25use sha2::{Digest, Sha256};
26
27use crate::provider::ProviderKind;
28
29pub const SESSION_CAP_ENV: &str = "M4A_SESSION_CAP";
31
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct SessionIdentity {
34 pub provider: ProviderKind,
35 pub provider_session_id: String,
37}
38
39impl SessionIdentity {
40 pub fn new(provider: ProviderKind, provider_session_id: &str) -> Result<Self, AccountError> {
41 let id = provider_session_id.trim();
42 if id.is_empty() || id.len() > 256 || id.chars().any(char::is_control) {
43 return Err(AccountError::BadIdentity);
44 }
45 Ok(Self { provider, provider_session_id: id.to_string() })
46 }
47
48 pub fn account_ref(&self) -> String {
50 let h = Sha256::digest(format!("m4a-session-account/v1\0{}\0{}", self.provider.id(), self.provider_session_id).as_bytes());
51 format!("{}-{}", self.provider.id(), hex(&h[..8]))
52 }
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
56#[serde(rename_all = "snake_case")]
57pub enum ServerState {
58 Local,
60 InvitePending,
62 Enrolled,
64 Retired,
66}
67
68#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
69pub struct AccountEntry {
70 pub nick: String,
71 pub provider: String,
72 pub state: ServerState,
73 #[serde(default)]
75 pub cap_sha256: Option<String>,
76 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub os_uid: Option<u32>,
80}
81
82#[derive(Debug, Default, Serialize, Deserialize)]
83struct RegistryFile {
84 accounts: BTreeMap<String, AccountEntry>,
86}
87
88#[derive(Debug, thiserror::Error, PartialEq, Eq)]
89pub enum AccountError {
90 #[error("session identity is empty or malformed")]
91 BadIdentity,
92 #[error("nick is not a valid slug")]
93 BadNick,
94 #[error("no account for this session")]
95 Unknown,
96 #[error("capability refused")]
97 Refused,
98 #[error("session capability missing")]
99 Missing,
100 #[error("caller is not the session's OS user")]
101 WrongCaller,
102 #[error("account registry io: {0}")]
103 Io(String),
104}
105
106pub struct AccountRegistry {
107 base: PathBuf,
108 file: RegistryFile,
109}
110
111impl AccountRegistry {
112 pub fn open(base: &Path) -> Result<Self, AccountError> {
113 let dir = base.join(".m4a-accounts");
114 create_private_dir(&dir)?;
115 let path = dir.join("registry.json");
116 let file = match std::fs::read(&path) {
117 Ok(b) => serde_json::from_slice(&b).map_err(|e| AccountError::Io(e.to_string()))?,
118 Err(e) if e.kind() == std::io::ErrorKind::NotFound => RegistryFile::default(),
119 Err(e) => return Err(AccountError::Io(e.to_string())),
120 };
121 Ok(Self { base: base.to_path_buf(), file })
122 }
123
124 pub fn provision(&mut self, identity: &SessionIdentity, base_nick: Option<&str>) -> Result<AccountEntry, AccountError> {
127 let r = identity.account_ref();
128 if let Some(e) = self.file.accounts.get(&r) {
129 return Ok(e.clone());
130 }
131 let stem = match base_nick {
132 Some(n) => slug(n).ok_or(AccountError::BadNick)?,
133 None => identity.provider.id().to_string(),
134 };
135 let taken = |n: &str| self.file.accounts.values().any(|e| e.nick == n);
136 let nick = if base_nick.is_some() && !taken(&stem) {
137 stem
138 } else {
139 (1..).map(|i| format!("{stem}-{i}")).find(|n| !taken(n)).expect("free nick")
140 };
141 let entry = AccountEntry { nick, provider: identity.provider.id().to_string(), state: ServerState::Local, cap_sha256: None, os_uid: None };
142 self.file.accounts.insert(r, entry.clone());
143 self.save()?;
144 create_private_dir(&self.store_root(identity))?;
145 Ok(entry)
146 }
147
148 pub fn renick(&mut self, identity: &SessionIdentity, nick: &str) -> Result<AccountEntry, AccountError> {
152 let ok = !nick.is_empty() && nick.len() <= 64 && nick.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'));
153 if !ok {
154 return Err(AccountError::BadNick);
155 }
156 let r = identity.account_ref();
157 if self.file.accounts.iter().any(|(k, e)| *k != r && e.nick.eq_ignore_ascii_case(nick)) {
158 return Err(AccountError::BadNick);
159 }
160 let e = self.file.accounts.get_mut(&r).ok_or(AccountError::Unknown)?;
161 if e.nick != nick {
162 if e.state == ServerState::Enrolled {
163 return Err(AccountError::Refused);
164 }
165 e.nick = nick.to_string();
166 }
167 let out = e.clone();
168 self.save()?;
169 Ok(out)
170 }
171
172 pub fn get(&self, identity: &SessionIdentity) -> Option<&AccountEntry> {
173 self.file.accounts.get(&identity.account_ref())
174 }
175
176 pub fn set_state(&mut self, identity: &SessionIdentity, state: ServerState) -> Result<(), AccountError> {
177 let e = self.file.accounts.get_mut(&identity.account_ref()).ok_or(AccountError::Unknown)?;
178 e.state = state;
179 self.save()
180 }
181
182 pub fn store_root(&self, identity: &SessionIdentity) -> PathBuf {
184 self.base.join("accounts").join(identity.account_ref())
185 }
186
187 pub fn issue_capability(&mut self, identity: &SessionIdentity) -> Result<String, AccountError> {
189 let e = self.file.accounts.get_mut(&identity.account_ref()).ok_or(AccountError::Unknown)?;
190 let raw = m4a_agent::vault::random_bytes(32);
191 let cap = hex(&raw);
192 e.cap_sha256 = Some(hex(&Sha256::digest(cap.as_bytes())));
193 self.save()?;
194 Ok(cap)
195 }
196
197 pub fn authorize(&self, identity: &SessionIdentity, cap: &str) -> Result<&AccountEntry, AccountError> {
199 let e = self.get(identity).ok_or(AccountError::Unknown)?;
200 let want = e.cap_sha256.as_deref().ok_or(AccountError::Refused)?;
201 let got = hex(&Sha256::digest(cap.trim().as_bytes()));
202 if ct_eq(want, &got) && e.state != ServerState::Retired {
203 Ok(e)
204 } else {
205 Err(AccountError::Refused)
206 }
207 }
208
209 pub fn accounts(&self) -> impl Iterator<Item = (&str, &AccountEntry)> {
211 self.file.accounts.iter().map(|(r, e)| (r.as_str(), e))
212 }
213
214 pub fn store_root_of(&self, account_ref: &str) -> PathBuf {
216 self.base.join("accounts").join(account_ref)
217 }
218
219 pub fn set_state_ref(&mut self, account_ref: &str, state: ServerState) -> Result<(), AccountError> {
221 let e = self.file.accounts.get_mut(account_ref).ok_or(AccountError::Unknown)?;
222 e.state = state;
223 self.save()
224 }
225
226 pub fn find_by_nick(&self, nick: &str) -> Option<(&str, &AccountEntry)> {
228 self.file.accounts.iter().find(|(_, e)| e.nick.eq_ignore_ascii_case(nick.trim())).map(|(r, e)| (r.as_str(), e))
229 }
230
231 pub fn set_os_uid(&mut self, identity: &SessionIdentity, uid: Option<u32>) -> Result<(), AccountError> {
233 let e = self.file.accounts.get_mut(&identity.account_ref()).ok_or(AccountError::Unknown)?;
234 e.os_uid = uid;
235 self.save()
236 }
237
238 pub fn gate(&self, nick: &str, cap: Option<&str>, peer_uid: Option<u32>, require: bool) -> Result<(), AccountError> {
243 let Some((_, e)) = self.find_by_nick(nick) else {
244 return if require { Err(AccountError::Unknown) } else { Ok(()) };
245 };
246 if e.state == ServerState::Retired {
247 return Err(AccountError::Refused);
248 }
249 let want = e.cap_sha256.as_deref().ok_or(AccountError::Refused)?;
250 let cap = cap.map(str::trim).filter(|c| !c.is_empty()).ok_or(AccountError::Missing)?;
251 if !ct_eq(want, &hex(&Sha256::digest(cap.as_bytes()))) {
252 return Err(AccountError::Refused);
253 }
254 match (e.os_uid, peer_uid) {
255 (Some(owner), Some(caller)) if owner != caller => Err(AccountError::WrongCaller),
256 _ => Ok(()),
257 }
258 }
259
260 fn save(&self) -> Result<(), AccountError> {
261 let dir = self.base.join(".m4a-accounts");
262 let tmp = dir.join("registry.json.tmp");
263 let bytes = serde_json::to_vec_pretty(&self.file).map_err(|e| AccountError::Io(e.to_string()))?;
264 write_private(&tmp, &bytes)?;
265 std::fs::rename(&tmp, dir.join("registry.json")).map_err(|e| AccountError::Io(e.to_string()))
266 }
267}
268
269pub struct Provisioned {
271 pub account_ref: String,
272 pub entry: AccountEntry,
273 pub store_root: PathBuf,
275 pub cap: Option<zeroize::Zeroizing<String>>,
277}
278
279impl Provisioned {
280 pub fn session_env(&self) -> Vec<(&'static str, String)> {
283 let mut v = vec![
284 ("M4A_SESSION_ID", self.account_ref.clone()),
285 ("M4A_NICK", self.entry.nick.clone()),
286 ("M4A_BOT_NAME", self.entry.nick.clone()),
287 ("M4A_STORE_ROOT", self.store_root.to_string_lossy().into_owned()),
288 ];
289 if let Some(cap) = &self.cap {
290 v.push((SESSION_CAP_ENV, cap.to_string()));
291 }
292 v
293 }
294}
295
296pub fn provision_session(base: &Path, provider: ProviderKind, provider_session_id: &str, nick: Option<&str>, rotate: bool) -> Result<Provisioned, AccountError> {
300 let identity = SessionIdentity::new(provider, provider_session_id)?;
301 let mut reg = AccountRegistry::open(base)?;
302 let mut entry = reg.provision(&identity, nick)?;
303 let cap = if rotate || entry.cap_sha256.is_none() {
304 let c = reg.issue_capability(&identity)?;
305 entry = reg.get(&identity).cloned().ok_or(AccountError::Unknown)?;
306 Some(zeroize::Zeroizing::new(c))
307 } else {
308 None
309 };
310 Ok(Provisioned { account_ref: identity.account_ref(), store_root: reg.store_root(&identity), entry, cap })
311}
312
313pub fn renick_session(base: &Path, provider: ProviderKind, provider_session_id: &str, nick: &str) -> Result<AccountEntry, AccountError> {
315 let identity = SessionIdentity::new(provider, provider_session_id)?;
316 let mut reg = AccountRegistry::open(base)?;
317 reg.renick(&identity, nick)
318}
319
320pub fn append_env_file(path: &Path, pairs: &[(&str, String)]) -> Result<(), AccountError> {
323 use std::io::Write;
324 let mut o = std::fs::OpenOptions::new();
325 o.create(true).append(true);
326 #[cfg(unix)]
327 {
328 use std::os::unix::fs::OpenOptionsExt;
329 o.mode(0o600);
330 }
331 let mut f = o.open(path).map_err(|e| AccountError::Io(e.to_string()))?;
332 for (k, v) in pairs {
333 if v.contains('\n') {
334 return Err(AccountError::Io("newline in env value".into()));
335 }
336 writeln!(f, "export {k}={v}").map_err(|e| AccountError::Io(e.to_string()))?;
337 }
338 Ok(())
339}
340
341pub const SESSION_FILE_KEYS: [&str; 7] = ["M4A_NICK", "M4A_SESSION_ID", SESSION_CAP_ENV, "M4A_BOT_NAME", "M4A_STORE_ROOT", "M4A_PRODUCT_URL", "M4A_HOMESERVER_URL"];
344
345pub fn parse_session_file(text: &str) -> Vec<(&'static str, String)> {
349 let mut out = Vec::new();
350 for line in text.lines() {
351 let line = line.trim();
352 let line = line.strip_prefix("export ").unwrap_or(line);
353 let Some((k, v)) = line.split_once('=') else { continue };
354 if let Some(key) = SESSION_FILE_KEYS.iter().find(|key| **key == k.trim()) {
355 let v = v.trim();
356 if !v.is_empty() && !v.chars().any(char::is_control) {
357 out.push((*key, v.to_owned()));
358 }
359 }
360 }
361 out
362}
363
364pub fn apply_session_file(path: &Path) -> Result<(), AccountError> {
366 let text = std::fs::read_to_string(path).map_err(|e| AccountError::Io(e.kind().to_string()))?;
367 let pairs = parse_session_file(&text);
368 if pairs.is_empty() {
369 return Err(AccountError::Io("no session identity in file".into()));
370 }
371 for (k, v) in pairs {
372 if std::env::var_os(k).filter(|x| !x.is_empty()).is_none() {
373 std::env::set_var(k, v);
374 }
375 }
376 Ok(())
377}
378
379pub fn mark_enrolled(store_root: &Path) -> Result<Option<String>, AccountError> {
383 let Some(r) = store_root.file_name().and_then(|n| n.to_str()).map(str::to_string) else { return Ok(None) };
384 let Some(base) = store_root.parent().filter(|p| p.file_name().is_some_and(|n| n == "accounts")).and_then(Path::parent) else { return Ok(None) };
385 if !base.join(".m4a-accounts").join("registry.json").exists() {
386 return Ok(None);
387 }
388 let mut reg = AccountRegistry::open(base)?;
389 if reg.file.accounts.contains_key(&r) {
390 reg.set_state_ref(&r, ServerState::Enrolled)?;
391 Ok(Some(r))
392 } else {
393 Ok(None)
394 }
395}
396
397fn ct_eq(a: &str, b: &str) -> bool {
398 a.len() == b.len() && a.bytes().zip(b.bytes()).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
399}
400
401fn hex(b: &[u8]) -> String {
402 b.iter().map(|x| format!("{x:02x}")).collect()
403}
404
405fn slug(s: &str) -> Option<String> {
406 crate::nick::routine_folder_id(s).filter(|n| !n.is_empty() && n.len() <= 64)
407}
408
409fn create_private_dir(p: &Path) -> Result<(), AccountError> {
410 std::fs::create_dir_all(p).map_err(|e| AccountError::Io(e.to_string()))?;
411 #[cfg(unix)]
412 {
413 use std::os::unix::fs::PermissionsExt;
414 std::fs::set_permissions(p, std::fs::Permissions::from_mode(0o700)).map_err(|e| AccountError::Io(e.to_string()))?;
415 }
416 Ok(())
417}
418
419fn write_private(p: &Path, b: &[u8]) -> Result<(), AccountError> {
420 #[cfg(unix)]
421 {
422 use std::io::Write;
423 use std::os::unix::fs::OpenOptionsExt;
424 let mut f = std::fs::OpenOptions::new().write(true).create(true).truncate(true).mode(0o600).open(p).map_err(|e| AccountError::Io(e.to_string()))?;
425 f.write_all(b).map_err(|e| AccountError::Io(e.to_string()))
426 }
427 #[cfg(not(unix))]
428 {
429 std::fs::write(p, b).map_err(|e| AccountError::Io(e.to_string()))
430 }
431}
432
433#[cfg(test)]
434mod tests {
435 use super::*;
436
437 fn base(tag: &str) -> PathBuf {
438 let p = std::env::temp_dir().join(format!("m4a-acct-{tag}-{}", std::process::id()));
439 let _ = std::fs::remove_dir_all(&p);
440 p
441 }
442 fn id(p: ProviderKind, s: &str) -> SessionIdentity { SessionIdentity::new(p, s).unwrap() }
443
444 #[test]
445 fn one_account_per_session_and_stable_across_reopen() {
446 let b = base("stable");
447 let mut r = AccountRegistry::open(&b).unwrap();
448 let a = r.provision(&id(ProviderKind::Codex, "thread-a"), None).unwrap();
449 let again = r.provision(&id(ProviderKind::Codex, "thread-a"), None).unwrap();
450 assert_eq!(a, again);
451 let other = r.provision(&id(ProviderKind::Codex, "thread-b"), None).unwrap();
452 assert_ne!(a.nick, other.nick, "two sessions of one provider get two accounts");
453 assert_eq!(AccountRegistry::open(&b).unwrap().get(&id(ProviderKind::Codex, "thread-a")).unwrap().nick, a.nick);
454 let _ = std::fs::remove_dir_all(&b);
455 }
456
457 #[test]
458 fn nick_is_never_shared_between_identities() {
459 let b = base("nick");
460 let mut r = AccountRegistry::open(&b).unwrap();
461 let x = r.provision(&id(ProviderKind::Codex, "t1"), Some("Home Codex")).unwrap();
462 let y = r.provision(&id(ProviderKind::Codex, "t2"), Some("home codex")).unwrap();
463 assert_eq!(x.nick, "home-codex");
464 assert_eq!(y.nick, "home-codex-1");
465 let k = r.provision(&id(ProviderKind::KimiCode, "t1"), None).unwrap();
466 assert_ne!(k.nick, x.nick, "same native id under another provider is another account");
467 let _ = std::fs::remove_dir_all(&b);
468 }
469
470 #[test]
471 fn foreign_capability_is_refused() {
472 let b = base("cap");
473 let mut r = AccountRegistry::open(&b).unwrap();
474 let (a, c) = (id(ProviderKind::ClaudeCode, "s-a"), id(ProviderKind::ClaudeCode, "s-c"));
475 r.provision(&a, None).unwrap();
476 r.provision(&c, None).unwrap();
477 let cap_a = r.issue_capability(&a).unwrap();
478 assert!(r.authorize(&a, &cap_a).is_ok());
479 assert_eq!(r.authorize(&c, &cap_a).unwrap_err(), AccountError::Refused, "c has no capability yet");
480 let cap_c = r.issue_capability(&c).unwrap();
481 assert_eq!(r.authorize(&a, &cap_c).unwrap_err(), AccountError::Refused);
482 let rotated = r.issue_capability(&a).unwrap();
483 assert!(r.authorize(&a, &cap_a).is_err() && r.authorize(&a, &rotated).is_ok());
484 r.set_state(&a, ServerState::Retired).unwrap();
485 assert!(r.authorize(&a, &rotated).is_err());
486 let raw = std::fs::read_to_string(b.join(".m4a-accounts/registry.json")).unwrap();
487 assert!(!raw.contains(&rotated) && !raw.contains("s-a"), "no capability or native id in clear");
488 let _ = std::fs::remove_dir_all(&b);
489 }
490
491 #[test]
492 fn each_account_has_its_own_private_store_root() {
493 let b = base("root");
494 let mut r = AccountRegistry::open(&b).unwrap();
495 let (a, c) = (id(ProviderKind::Grok, "g1"), id(ProviderKind::Grok, "g2"));
496 r.provision(&a, None).unwrap();
497 r.provision(&c, None).unwrap();
498 assert_ne!(r.store_root(&a), r.store_root(&c));
499 #[cfg(unix)]
500 {
501 use std::os::unix::fs::PermissionsExt;
502 assert_eq!(std::fs::metadata(r.store_root(&a)).unwrap().permissions().mode() & 0o777, 0o700);
503 assert_eq!(std::fs::metadata(b.join(".m4a-accounts/registry.json")).unwrap().permissions().mode() & 0o777, 0o600);
504 }
505 let _ = std::fs::remove_dir_all(&b);
506 }
507
508 #[test]
509 fn identity_rejects_empty_and_control_chars() {
510 assert!(SessionIdentity::new(ProviderKind::Codex, " ").is_err());
511 assert!(SessionIdentity::new(ProviderKind::Codex, "a\nb").is_err());
512 }
513
514 #[test]
515 fn two_sessions_same_provider_get_distinct_accounts_roots_and_logins() {
516 let dir = base("s2-two");
517 let a = provision_session(&dir, ProviderKind::Codex, "thread-a", Some("codex"), false).unwrap();
518 let b = provision_session(&dir, ProviderKind::Codex, "thread-b", Some("codex"), false).unwrap();
519 assert_ne!(a.entry.nick, b.entry.nick);
520 assert_ne!(a.store_root, b.store_root);
521 assert_ne!(a.account_ref, b.account_ref);
522 let env_a: Vec<_> = a.session_env().into_iter().map(|(k, _)| k).collect();
523 assert!(env_a.contains(&SESSION_CAP_ENV));
524 let a2 = provision_session(&dir, ProviderKind::Codex, "thread-a", Some("codex"), false).unwrap();
526 assert!(a2.cap.is_none());
527 assert_eq!(a2.entry.nick, a.entry.nick);
528 }
529
530 #[test]
531 fn gate_refuses_foreign_missing_and_wrong_caller() {
532 let dir = base("s2-gate");
533 let a = provision_session(&dir, ProviderKind::Codex, "thread-a", None, false).unwrap();
534 let b = provision_session(&dir, ProviderKind::Codex, "thread-b", None, false).unwrap();
535 let (ca, cb) = (a.cap.unwrap(), b.cap.unwrap());
536 let mut reg = AccountRegistry::open(&dir).unwrap();
537 assert_eq!(reg.gate(&a.entry.nick, Some(&ca), None, true), Ok(()));
538 assert_eq!(reg.gate(&a.entry.nick, Some(&cb), None, true), Err(AccountError::Refused));
539 assert_eq!(reg.gate(&a.entry.nick, None, None, false), Err(AccountError::Missing));
540 assert_eq!(reg.gate("operator-bot", None, None, false), Ok(()));
541 assert_eq!(reg.gate("operator-bot", None, None, true), Err(AccountError::Unknown));
542 let ida = SessionIdentity::new(ProviderKind::Codex, "thread-a").unwrap();
543 reg.set_os_uid(&ida, Some(1000)).unwrap();
544 assert_eq!(reg.gate(&a.entry.nick, Some(&ca), Some(1001), true), Err(AccountError::WrongCaller));
545 assert_eq!(reg.gate(&a.entry.nick, Some(&ca), Some(1000), true), Ok(()));
546 reg.set_state(&ida, ServerState::Retired).unwrap();
547 assert_eq!(reg.gate(&a.entry.nick, Some(&ca), Some(1000), true), Err(AccountError::Refused));
548 }
549
550 #[test]
551 fn enroll_marks_only_that_account_and_daemon_lists_it_from_its_own_root() {
552 let dir = base("s3-enroll");
553 let a = provision_session(&dir, ProviderKind::KimiCode, "a", Some("kimi"), false).unwrap();
554 let b = provision_session(&dir, ProviderKind::KimiCode, "b", Some("kimi"), false).unwrap();
555 assert!(crate::account_sessions(&dir).is_empty(), "local accounts are not opened on the server");
556 assert_eq!(mark_enrolled(&a.store_root).unwrap(), Some(a.account_ref.clone()));
557 assert_eq!(mark_enrolled(&dir.join("elsewhere")).unwrap(), None);
558 let listed = crate::account_sessions(&dir);
559 assert_eq!(listed.len(), 1);
560 assert_eq!(listed[0].bot_name, a.entry.nick);
561 assert_eq!(listed[0].session_id, a.account_ref);
562 assert_eq!(listed[0].store_root.as_deref(), Some(a.store_root.as_path()));
563 assert_ne!(listed[0].store_root.as_deref(), Some(b.store_root.as_path()));
564 }
565
566 #[test]
567 fn session_file_keeps_only_identity_keys() {
568 let got = parse_session_file("export M4A_NICK=a-1\nM4A_SESSION_ID=s\nPATH=/x\nM4A_SESSION_CAP=\n# c\n");
569 assert_eq!(parse_session_file("M4A_STORE_ROOT=/r\nM4A_PRODUCT_URL=u\nLD_PRELOAD=x\n"), vec![("M4A_STORE_ROOT", "/r".to_owned()), ("M4A_PRODUCT_URL", "u".to_owned())]);
570 assert_eq!(got, vec![("M4A_NICK", "a-1".to_owned()), ("M4A_SESSION_ID", "s".to_owned())]);
571 }
572
573 #[test]
574 fn env_file_is_private_and_rejects_newlines() {
575 let dir = base("s2-env");
576 std::fs::create_dir_all(&dir).unwrap();
577 let p = dir.join("env");
578 append_env_file(&p, &[("M4A_NICK", "x".into())]).unwrap();
579 assert!(append_env_file(&p, &[("M4A_NICK", "x\ny".into())]).is_err());
580 assert_eq!(std::fs::read_to_string(&p).unwrap(), "export M4A_NICK=x\n");
581 #[cfg(unix)]
582 {
583 use std::os::unix::fs::PermissionsExt;
584 assert_eq!(std::fs::metadata(&p).unwrap().permissions().mode() & 0o777, 0o600);
585 }
586 }
587}