1use std::collections::BTreeMap;
33use std::fs;
34use std::io::IsTerminal;
35use std::io::Read;
36use std::path::{Path, PathBuf};
37use std::time::Duration;
38
39use semver::Version;
40use sha2::{Digest, Sha256};
41
42use crate::error::{CtxError, Result};
43
44const DEFAULT_BASE_URL: &str = "https://api.github.com/repos/agentis-tools/ctx";
46
47pub const PASSIVE_CHECK_INTERVAL_SECS: u64 = 24 * 60 * 60;
49
50const STAMP_FILE: &str = "last-update-check";
53
54const PASSIVE_TIMEOUT: Duration = Duration::from_secs(1);
56
57const EXPLICIT_TIMEOUT: Duration = Duration::from_secs(30);
59
60pub fn release_target() -> Option<&'static str> {
70 if cfg!(all(
71 target_os = "linux",
72 target_arch = "x86_64",
73 target_env = "gnu"
74 )) {
75 Some("x86_64-unknown-linux-gnu")
76 } else if cfg!(all(target_os = "macos", target_arch = "x86_64")) {
77 Some("x86_64-apple-darwin")
78 } else if cfg!(all(target_os = "macos", target_arch = "aarch64")) {
79 Some("aarch64-apple-darwin")
80 } else if cfg!(all(
81 target_os = "windows",
82 target_arch = "x86_64",
83 target_env = "msvc"
84 )) {
85 Some("x86_64-pc-windows-msvc")
86 } else {
87 None
88 }
89}
90
91pub fn artifact_name(tag: &str, target: &str) -> String {
96 let ext = if target.contains("windows") {
97 "zip"
98 } else {
99 "tar.gz"
100 };
101 format!("ctx-{tag}-{target}.{ext}")
102}
103
104pub fn current_version() -> Version {
106 Version::parse(env!("CARGO_PKG_VERSION")).expect("CARGO_PKG_VERSION is valid semver")
107}
108
109#[derive(Debug, Clone)]
115pub struct Asset {
116 pub name: String,
117 pub download_url: String,
118}
119
120#[derive(Debug, Clone)]
122pub struct Release {
123 pub tag: String,
124 pub assets: Vec<Asset>,
125}
126
127impl Release {
128 pub fn version(&self) -> Result<Version> {
130 Version::parse(self.tag.trim_start_matches('v'))
131 .map_err(|e| CtxError::Other(format!("release tag '{}' is not semver: {e}", self.tag)))
132 }
133
134 pub fn asset(&self, name: &str) -> Option<&Asset> {
136 self.assets.iter().find(|a| a.name == name)
137 }
138}
139
140fn base_url() -> String {
142 std::env::var("CTX_UPDATE_BASE_URL").unwrap_or_else(|_| DEFAULT_BASE_URL.to_string())
143}
144
145fn http_client(timeout: Duration) -> Result<reqwest::blocking::Client> {
146 reqwest::blocking::Client::builder()
147 .timeout(timeout)
148 .user_agent(concat!("ctx/", env!("CARGO_PKG_VERSION")))
149 .build()
150 .map_err(CtxError::Network)
151}
152
153pub fn latest_release(client: &reqwest::blocking::Client) -> Result<Release> {
155 fetch_release(client, &format!("{}/releases/latest", base_url()))
156}
157
158pub fn release_by_tag(client: &reqwest::blocking::Client, tag: &str) -> Result<Release> {
160 fetch_release(client, &format!("{}/releases/tags/{tag}", base_url()))
161}
162
163fn fetch_release(client: &reqwest::blocking::Client, url: &str) -> Result<Release> {
164 let response = client
165 .get(url)
166 .header("Accept", "application/vnd.github+json")
167 .send()?;
168 let status = response.status();
169 if !status.is_success() {
170 return Err(CtxError::Other(format!(
171 "release query failed: {url} returned HTTP {status}"
172 )));
173 }
174 let value: serde_json::Value = response.json()?;
175 parse_release(&value)
176}
177
178pub fn parse_release(value: &serde_json::Value) -> Result<Release> {
180 let tag = value["tag_name"]
181 .as_str()
182 .ok_or_else(|| CtxError::Other("release JSON has no tag_name".to_string()))?
183 .to_string();
184 let assets = value["assets"]
185 .as_array()
186 .map(|assets| {
187 assets
188 .iter()
189 .filter_map(|a| {
190 Some(Asset {
191 name: a["name"].as_str()?.to_string(),
192 download_url: a["browser_download_url"].as_str()?.to_string(),
193 })
194 })
195 .collect()
196 })
197 .unwrap_or_default();
198 Ok(Release { tag, assets })
199}
200
201pub fn parse_sha256sums(text: &str) -> BTreeMap<String, String> {
209 let mut sums = BTreeMap::new();
210 for line in text.lines() {
211 let line = line.trim();
212 if line.is_empty() {
213 continue;
214 }
215 let Some((hex, name)) = line.split_once(char::is_whitespace) else {
216 continue;
217 };
218 let name = name.trim_start().trim_start_matches('*');
219 if hex.len() == 64 && hex.chars().all(|c| c.is_ascii_hexdigit()) && !name.is_empty() {
220 sums.insert(name.to_string(), hex.to_ascii_lowercase());
221 }
222 }
223 sums
224}
225
226fn sha256_hex(data: &[u8]) -> String {
227 let digest = Sha256::digest(data);
228 digest.iter().map(|b| format!("{b:02x}")).collect()
229}
230
231fn extract_binary(archive: &[u8], artifact: &str) -> Result<Vec<u8>> {
240 if artifact.ends_with(".tar.gz") {
241 return extract_from_tar_gz(archive);
242 }
243 #[cfg(windows)]
244 if artifact.ends_with(".zip") {
245 return extract_from_zip(archive);
246 }
247 Err(CtxError::Other(format!(
248 "cannot extract '{artifact}': unsupported archive format for this platform"
249 )))
250}
251
252fn extract_from_tar_gz(archive: &[u8]) -> Result<Vec<u8>> {
253 let mut tar = tar::Archive::new(flate2::read::GzDecoder::new(archive));
254 for entry in tar.entries()? {
255 let mut entry = entry?;
256 if !entry.header().entry_type().is_file() {
257 continue;
258 }
259 let is_ctx = entry.path()?.file_name().is_some_and(|name| name == "ctx");
260 if is_ctx {
261 let mut binary = Vec::new();
262 entry.read_to_end(&mut binary)?;
263 return Ok(binary);
264 }
265 }
266 Err(CtxError::Other(
267 "release archive contains no 'ctx' binary member".to_string(),
268 ))
269}
270
271#[cfg(windows)]
272fn extract_from_zip(archive: &[u8]) -> Result<Vec<u8>> {
273 let cursor = std::io::Cursor::new(archive);
274 let mut zip = zip::ZipArchive::new(cursor)
275 .map_err(|e| CtxError::Other(format!("invalid release zip: {e}")))?;
276 for i in 0..zip.len() {
277 let mut entry = zip
278 .by_index(i)
279 .map_err(|e| CtxError::Other(format!("invalid release zip entry: {e}")))?;
280 if entry.is_dir() {
281 continue;
282 }
283 let is_ctx = entry
284 .enclosed_name()
285 .and_then(|p| p.file_name().map(|n| n.to_os_string()))
286 .is_some_and(|name| name == "ctx.exe");
287 if is_ctx {
288 let mut binary = Vec::new();
289 entry.read_to_end(&mut binary)?;
290 return Ok(binary);
291 }
292 }
293 Err(CtxError::Other(
294 "release archive contains no 'ctx.exe' binary member".to_string(),
295 ))
296}
297
298fn ensure_writable(dir: &Path) -> Result<()> {
305 let probe = dir.join(format!(".ctx-write-probe-{}", std::process::id()));
306 let outcome = fs::write(&probe, b"").and_then(|()| fs::remove_file(&probe));
307 outcome.map_err(|e| {
308 CtxError::Other(format!(
309 "cannot update: install location '{}' is not writable ({e}); \
310 re-run with elevated permissions or update through the package \
311 manager that installed ctx (e.g. 'cargo install agentis-ctx')",
312 dir.display()
313 ))
314 })
315}
316
317fn replace_executable(exe: &Path, data: &[u8]) -> Result<()> {
325 let dir = exe
326 .parent()
327 .ok_or_else(|| CtxError::Other("executable has no parent directory".to_string()))?;
328 let staged = dir.join(format!(".ctx-update-{}", std::process::id()));
329 if let Err(e) = fs::write(&staged, data) {
330 let _ = fs::remove_file(&staged);
331 return Err(e.into());
332 }
333
334 #[cfg(unix)]
335 {
336 use std::os::unix::fs::PermissionsExt;
337 if let Err(e) = fs::set_permissions(&staged, fs::Permissions::from_mode(0o755)) {
338 let _ = fs::remove_file(&staged);
339 return Err(e.into());
340 }
341 if let Err(e) = fs::rename(&staged, exe) {
342 let _ = fs::remove_file(&staged);
343 return Err(e.into());
344 }
345 }
346
347 #[cfg(windows)]
348 {
349 let old = old_binary_path(exe);
350 let _ = fs::remove_file(&old);
351 if let Err(e) = fs::rename(exe, &old) {
352 let _ = fs::remove_file(&staged);
353 return Err(e.into());
354 }
355 if let Err(e) = fs::rename(&staged, exe) {
356 let _ = fs::rename(&old, exe);
358 let _ = fs::remove_file(&staged);
359 return Err(e.into());
360 }
361 }
362
363 Ok(())
364}
365
366#[cfg(windows)]
367fn old_binary_path(exe: &Path) -> PathBuf {
368 let mut name = exe
369 .file_name()
370 .map(|n| n.to_os_string())
371 .unwrap_or_default();
372 name.push(".old");
373 exe.with_file_name(name)
374}
375
376#[derive(Debug, Clone)]
382pub struct SelfUpdateReport {
383 pub old_version: Version,
384 pub new_version: Version,
385 pub updated: bool,
387}
388
389pub fn self_update(pin: Option<&str>) -> Result<SelfUpdateReport> {
398 let exe = std::env::current_exe()?;
399 let dir = exe
400 .parent()
401 .ok_or_else(|| CtxError::Other("cannot locate the running executable".to_string()))?;
402
403 #[cfg(windows)]
405 {
406 let _ = fs::remove_file(old_binary_path(&exe));
407 }
408
409 ensure_writable(dir)?;
411
412 let client = http_client(EXPLICIT_TIMEOUT)?;
413 let release = match pin {
414 Some(version) => {
415 let tag = format!("v{}", version.trim_start_matches('v'));
416 release_by_tag(&client, &tag)?
417 }
418 None => latest_release(&client)?,
419 };
420 let new_version = release.version()?;
421 let old_version = current_version();
422
423 let update_needed = match pin {
426 Some(_) => new_version != old_version,
427 None => new_version > old_version,
428 };
429 if !update_needed {
430 return Ok(SelfUpdateReport {
431 old_version,
432 new_version,
433 updated: false,
434 });
435 }
436
437 let target = release_target().ok_or_else(|| {
438 CtxError::Other(format!(
439 "no prebuilt release artifact for this platform ({}-{}); \
440 update with 'cargo install agentis-ctx' instead",
441 std::env::consts::ARCH,
442 std::env::consts::OS
443 ))
444 })?;
445 let artifact = artifact_name(&release.tag, target);
446 let asset = release.asset(&artifact).ok_or_else(|| {
447 CtxError::Other(format!(
448 "release {} has no artifact named '{artifact}'",
449 release.tag
450 ))
451 })?;
452 let sums_asset = release.asset("SHA256SUMS").ok_or_else(|| {
453 CtxError::Other(format!(
454 "release {} publishes no SHA256SUMS file; refusing to install an unverifiable binary",
455 release.tag
456 ))
457 })?;
458
459 let sums_text = download_text(&client, &sums_asset.download_url)?;
460 let sums = parse_sha256sums(&sums_text);
461 let expected = sums.get(&artifact).ok_or_else(|| {
462 CtxError::Other(format!(
463 "SHA256SUMS for release {} has no entry for '{artifact}'",
464 release.tag
465 ))
466 })?;
467
468 let archive = download_bytes(&client, &asset.download_url)?;
469 let actual = sha256_hex(&archive);
470 if actual != *expected {
471 return Err(CtxError::Other(format!(
472 "checksum mismatch for '{artifact}': expected {expected}, got {actual}; \
473 aborting update (the installed binary is unchanged)"
474 )));
475 }
476
477 let binary = extract_binary(&archive, &artifact)?;
478 replace_executable(&exe, &binary)?;
479
480 Ok(SelfUpdateReport {
481 old_version,
482 new_version,
483 updated: true,
484 })
485}
486
487fn download_text(client: &reqwest::blocking::Client, url: &str) -> Result<String> {
488 let response = client.get(url).send()?;
489 let status = response.status();
490 if !status.is_success() {
491 return Err(CtxError::Other(format!(
492 "download failed: {url} returned HTTP {status}"
493 )));
494 }
495 Ok(response.text()?)
496}
497
498fn download_bytes(client: &reqwest::blocking::Client, url: &str) -> Result<Vec<u8>> {
499 let response = client.get(url).send()?;
500 let status = response.status();
501 if !status.is_success() {
502 return Err(CtxError::Other(format!(
503 "download failed: {url} returned HTTP {status}"
504 )));
505 }
506 Ok(response.bytes()?.to_vec())
507}
508
509pub fn update_notice(latest: &Version, current: &Version) -> String {
516 format!("ctx {latest} available (you have {current}) — run 'ctx self-update'")
517}
518
519pub fn explicit_check() -> Result<(Version, Version)> {
524 let client = http_client(EXPLICIT_TIMEOUT)?;
525 let latest = latest_release(&client)?.version()?;
526 Ok((current_version(), latest))
527}
528
529pub fn cache_dir() -> Option<PathBuf> {
536 if let Ok(dir) = std::env::var("CTX_CACHE_DIR") {
537 if !dir.is_empty() {
538 return Some(PathBuf::from(dir));
539 }
540 }
541 dirs::cache_dir().map(|d| d.join("ctx"))
542}
543
544pub fn check_is_due(stamp_file: &Path, now: u64) -> bool {
548 let last = fs::read_to_string(stamp_file)
549 .ok()
550 .and_then(|s| s.trim().parse::<u64>().ok());
551 match last {
552 Some(last) => now >= last.saturating_add(PASSIVE_CHECK_INTERVAL_SECS),
553 None => true,
554 }
555}
556
557pub fn record_check(stamp_file: &Path, now: u64) -> std::io::Result<()> {
559 if let Some(parent) = stamp_file.parent() {
560 fs::create_dir_all(parent)?;
561 }
562 fs::write(stamp_file, format!("{now}\n"))
563}
564
565pub fn passive_check_allowed<F>(json: bool, stderr_is_tty: bool, env: F) -> bool
577where
578 F: Fn(&str) -> Option<String>,
579{
580 if json || !stderr_is_tty {
581 return false;
582 }
583 if env("CTX_NO_UPDATE_CHECK").is_some_and(|v| !v.is_empty()) {
584 return false;
585 }
586 for var in ["CLAUDECODE", "CLAUDE_PROJECT_DIR", "CLAUDE_PLUGIN_ROOT"] {
588 if env(var).is_some() {
589 return false;
590 }
591 }
592 true
593}
594
595pub fn passive_check(json: bool) {
606 let stderr_is_tty = std::env::var("CTX_UPDATE_FORCE_TTY").map(|v| v == "1") == Ok(true)
609 || std::io::stderr().is_terminal();
610 if !passive_check_allowed(json, stderr_is_tty, |k| std::env::var(k).ok()) {
611 return;
612 }
613 let _ = passive_check_inner();
616}
617
618fn passive_check_inner() -> Result<()> {
619 let dir = cache_dir().ok_or_else(|| CtxError::Other("no cache dir".to_string()))?;
620 let stamp = dir.join(STAMP_FILE);
621 let now = std::time::SystemTime::now()
622 .duration_since(std::time::UNIX_EPOCH)
623 .map(|d| d.as_secs())
624 .unwrap_or(0);
625 if !check_is_due(&stamp, now) {
626 return Ok(());
627 }
628 record_check(&stamp, now)?;
631
632 let client = http_client(PASSIVE_TIMEOUT)?;
633 let latest = latest_release(&client)?.version()?;
634 let current = current_version();
635 if latest > current {
636 eprintln!("{}", update_notice(&latest, ¤t));
637 }
638 Ok(())
639}
640
641#[cfg(test)]
646mod tests {
647 use super::*;
648
649 #[test]
652 fn test_artifact_names_mirror_release_workflow() {
653 let expected = [
655 (
656 "x86_64-unknown-linux-gnu",
657 "ctx-v0.3.0-x86_64-unknown-linux-gnu.tar.gz",
658 ),
659 (
660 "x86_64-apple-darwin",
661 "ctx-v0.3.0-x86_64-apple-darwin.tar.gz",
662 ),
663 (
664 "aarch64-apple-darwin",
665 "ctx-v0.3.0-aarch64-apple-darwin.tar.gz",
666 ),
667 (
668 "x86_64-pc-windows-msvc",
669 "ctx-v0.3.0-x86_64-pc-windows-msvc.zip",
670 ),
671 ];
672 for (target, name) in expected {
673 assert_eq!(artifact_name("v0.3.0", target), name);
674 }
675 }
676
677 #[test]
678 fn test_release_target_matches_compile_target() {
679 let expected = if cfg!(all(
680 target_os = "linux",
681 target_arch = "x86_64",
682 target_env = "gnu"
683 )) {
684 Some("x86_64-unknown-linux-gnu")
685 } else if cfg!(all(target_os = "macos", target_arch = "x86_64")) {
686 Some("x86_64-apple-darwin")
687 } else if cfg!(all(target_os = "macos", target_arch = "aarch64")) {
688 Some("aarch64-apple-darwin")
689 } else if cfg!(all(
690 target_os = "windows",
691 target_arch = "x86_64",
692 target_env = "msvc"
693 )) {
694 Some("x86_64-pc-windows-msvc")
695 } else {
696 None
697 };
698 assert_eq!(release_target(), expected);
699 if let Some(target) = release_target() {
701 let name = artifact_name("v1.0.0", target);
702 assert!(name.starts_with("ctx-v1.0.0-"));
703 assert!(name.ends_with(".tar.gz") || name.ends_with(".zip"));
704 }
705 }
706
707 #[test]
710 fn test_parse_sha256sums_multi_line() {
711 let hex_a = "a".repeat(64);
712 let hex_b = "B".repeat(64);
713 let text = format!(
714 "{hex_a} ctx-v0.3.0-x86_64-unknown-linux-gnu.tar.gz\n\
715 \n\
716 {hex_b} *ctx-v0.3.0-x86_64-pc-windows-msvc.zip\n\
717 not-a-sum-line\n\
718 deadbeef too-short-digest.tar.gz\n"
719 );
720 let sums = parse_sha256sums(&text);
721 assert_eq!(sums.len(), 2);
722 assert_eq!(
723 sums["ctx-v0.3.0-x86_64-unknown-linux-gnu.tar.gz"], hex_a,
724 "plain entry parses"
725 );
726 assert_eq!(
728 sums["ctx-v0.3.0-x86_64-pc-windows-msvc.zip"],
729 hex_b.to_ascii_lowercase()
730 );
731 }
732
733 #[test]
734 fn test_sha256_hex_matches_known_vector() {
735 assert_eq!(
737 sha256_hex(b"abc"),
738 "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
739 );
740 }
741
742 #[test]
745 fn test_parse_release_and_version_comparison() {
746 let value = serde_json::json!({
747 "tag_name": "v0.3.0",
748 "assets": [
749 {"name": "SHA256SUMS", "browser_download_url": "https://x/SHA256SUMS"},
750 {"name": "ctx-v0.3.0-aarch64-apple-darwin.tar.gz",
751 "browser_download_url": "https://x/ctx.tar.gz"},
752 {"malformed": true}
753 ]
754 });
755 let release = parse_release(&value).unwrap();
756 assert_eq!(release.tag, "v0.3.0");
757 assert_eq!(release.assets.len(), 2, "malformed asset entries skipped");
758 assert_eq!(release.version().unwrap(), Version::new(0, 3, 0));
759 assert!(release.asset("SHA256SUMS").is_some());
760 assert!(release.asset("nope.tar.gz").is_none());
761
762 assert!(Version::new(1, 0, 0) > Version::new(0, 9, 9));
764 assert!(Version::parse("0.3.0-rc.1").unwrap() < Version::new(0, 3, 0));
765 }
766
767 #[test]
770 fn test_check_is_due_with_injected_clock() {
771 let temp = tempfile::tempdir().unwrap();
772 let stamp = temp.path().join("nested").join("last-update-check");
773
774 assert!(check_is_due(&stamp, 1_000_000));
776
777 record_check(&stamp, 1_000_000).unwrap();
778 assert!(!check_is_due(&stamp, 1_000_000));
780 assert!(!check_is_due(
781 &stamp,
782 1_000_000 + PASSIVE_CHECK_INTERVAL_SECS - 1
783 ));
784 assert!(check_is_due(
786 &stamp,
787 1_000_000 + PASSIVE_CHECK_INTERVAL_SECS
788 ));
789
790 fs::write(&stamp, "not-a-number").unwrap();
792 assert!(check_is_due(&stamp, 1_000_000));
793 }
794
795 #[test]
798 fn test_passive_check_suppression_matrix() {
799 let no_env = |_: &str| -> Option<String> { None };
800 let env_with = |key: &'static str| move |k: &str| (k == key).then(|| "1".to_string());
801
802 assert!(passive_check_allowed(false, true, no_env));
804
805 assert!(!passive_check_allowed(true, true, no_env));
807 assert!(!passive_check_allowed(false, false, no_env));
809 assert!(!passive_check_allowed(
811 false,
812 true,
813 env_with("CTX_NO_UPDATE_CHECK")
814 ));
815 assert!(passive_check_allowed(false, true, |k: &str| (k
817 == "CTX_NO_UPDATE_CHECK")
818 .then(String::new)));
819 for var in ["CLAUDECODE", "CLAUDE_PROJECT_DIR", "CLAUDE_PLUGIN_ROOT"] {
821 assert!(
822 !passive_check_allowed(false, true, env_with(var)),
823 "{var} must suppress the passive check"
824 );
825 }
826 assert!(!passive_check_allowed(true, false, env_with("CLAUDECODE")));
828 }
829
830 #[test]
833 fn test_update_notice_format() {
834 let notice = update_notice(&Version::new(9, 9, 9), &Version::new(0, 2, 1));
835 assert_eq!(
836 notice,
837 "ctx 9.9.9 available (you have 0.2.1) — run 'ctx self-update'"
838 );
839 }
840
841 #[test]
844 fn test_extract_binary_from_tar_gz() {
845 let payload = b"#!/bin/sh\necho fake ctx\n".to_vec();
846 let gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
847 let mut builder = tar::Builder::new(gz);
848 let mut header = tar::Header::new_gnu();
849 header.set_size(payload.len() as u64);
850 header.set_mode(0o755);
851 header.set_cksum();
852 builder
853 .append_data(
854 &mut header,
855 "ctx-v9.9.9-aarch64-apple-darwin/ctx",
856 payload.as_slice(),
857 )
858 .unwrap();
859 let archive = builder.into_inner().unwrap().finish().unwrap();
860
861 let extracted = extract_binary(&archive, "ctx-v9.9.9-aarch64-apple-darwin.tar.gz").unwrap();
862 assert_eq!(extracted, payload);
863 }
864
865 #[test]
866 fn test_extract_binary_missing_member_errors() {
867 let gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
868 let mut builder = tar::Builder::new(gz);
869 let mut header = tar::Header::new_gnu();
870 header.set_size(5);
871 header.set_mode(0o644);
872 header.set_cksum();
873 builder
874 .append_data(&mut header, "dir/README.md", &b"hello"[..])
875 .unwrap();
876 let archive = builder.into_inner().unwrap().finish().unwrap();
877
878 let err = extract_binary(&archive, "ctx-v9.9.9-x.tar.gz").unwrap_err();
879 assert!(err.to_string().contains("no 'ctx' binary member"));
880 }
881
882 #[cfg(unix)]
885 #[test]
886 fn test_replace_executable_atomically_swaps_content() {
887 use std::os::unix::fs::PermissionsExt;
888
889 let temp = tempfile::tempdir().unwrap();
890 let exe = temp.path().join("ctx");
891 fs::write(&exe, b"old binary").unwrap();
892
893 replace_executable(&exe, b"new binary").unwrap();
894 assert_eq!(fs::read(&exe).unwrap(), b"new binary");
895 let mode = fs::metadata(&exe).unwrap().permissions().mode();
896 assert_eq!(mode & 0o755, 0o755, "replacement is executable");
897
898 let leftovers: Vec<_> = fs::read_dir(temp.path())
900 .unwrap()
901 .filter_map(|e| e.ok())
902 .filter(|e| e.file_name() != "ctx")
903 .collect();
904 assert!(leftovers.is_empty(), "leftovers: {leftovers:?}");
905 }
906
907 #[test]
908 fn test_ensure_writable_accepts_tempdir() {
909 let temp = tempfile::tempdir().unwrap();
910 ensure_writable(temp.path()).unwrap();
911 }
912
913 #[cfg(unix)]
914 #[test]
915 fn test_ensure_writable_rejects_readonly_dir() {
916 use std::os::unix::fs::PermissionsExt;
917
918 let temp = tempfile::tempdir().unwrap();
919 let dir = temp.path().join("ro");
920 fs::create_dir(&dir).unwrap();
921 fs::set_permissions(&dir, fs::Permissions::from_mode(0o555)).unwrap();
922
923 let err = ensure_writable(&dir).unwrap_err();
924 assert!(err.to_string().contains("not writable"), "{err}");
925
926 fs::set_permissions(&dir, fs::Permissions::from_mode(0o755)).unwrap();
928 }
929}