1use std::io::{Read, Write};
25use std::path::{Path, PathBuf};
26
27use murmur_artifact::{LockOrigin, PinnedDownload, ProgramDownloads};
28use serde::{Deserialize, Serialize};
29
30pub const PROGRAMS_DIR: &str = "programs";
32
33pub const RECORD_FILE: &str = "record.json";
35
36pub const FILES_DIR: &str = "files";
38
39pub const BIN_DIR: &str = "bin";
41
42pub const PARTIAL_PREFIX: &str = ".partial-";
44
45pub const MAX_PINNED_DOWNLOAD_BYTES: u64 = 2 * 1024 * 1024 * 1024;
47
48pub const EXECUTABLE_FILE_MODE: u32 = 0o700;
52
53const DOWNLOAD_FILE: &str = "download";
55
56pub fn program_store_root() -> Result<PathBuf, String> {
58 Ok(crate::state_store::murmur_home_dir()?.join(PROGRAMS_DIR))
59}
60
61pub fn ensure_program_store() -> Result<PathBuf, String> {
63 let root = crate::murmur_home::ensure_murmur_home()?.join(PROGRAMS_DIR);
64 crate::murmur_home::hold_private_dir(&root)
65 .map_err(|reason| format!("{}: {reason}", root.display()))?;
66 Ok(root)
67}
68
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
71pub struct PinnedRecord {
72 pub url: String,
73 pub sha256: String,
75 pub platform: String,
76 pub run: Option<String>,
78 pub tree_sha256: String,
80 pub fetched_at: String,
82 pub files: Vec<TreeFile>,
84}
85
86impl PinnedRecord {
87 pub fn program_file(&self) -> Option<&str> {
89 match &self.run {
90 Some(run) => Some(run.as_str()),
91 None => self.files.first().map(|file| file.path.as_str()),
92 }
93 }
94}
95
96#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
98pub struct TreeFile {
99 pub path: String,
101 pub executable: bool,
103 pub sha256: String,
104}
105
106pub fn tree_digest(files: &[TreeFile]) -> String {
108 let mut sorted: Vec<&TreeFile> = files.iter().collect();
109 sorted.sort();
110 let lines: String = sorted
111 .into_iter()
112 .map(|file| {
113 let mark = if file.executable { "x" } else { "-" };
114 format!("{}\t{mark}\t{}\n", file.path, file.sha256)
115 })
116 .collect();
117 murmur_artifact::sha256_hex(lines.as_bytes())
118}
119
120#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
122pub enum PinnedStoreError {
123 #[error("{0}")]
126 Unpack(String),
127 #[error("{0}")]
129 Store(String),
130}
131
132#[derive(Debug)]
135pub struct PartialDownload {
136 dir: PathBuf,
137 kept: bool,
138}
139
140impl PartialDownload {
141 pub fn begin(store_root: &Path) -> Result<Self, PinnedStoreError> {
143 let mut random = [0u8; 8];
144 getrandom::fill(&mut random)
145 .map_err(|err| PinnedStoreError::Store(format!("no randomness for a name: {err}")))?;
146 let dir = store_root.join(format!(
147 "{PARTIAL_PREFIX}{:016x}",
148 u64::from_be_bytes(random)
149 ));
150 create_private_dir(&dir)?;
151 Ok(Self { dir, kept: false })
152 }
153
154 pub fn download_path(&self) -> PathBuf {
156 self.dir.join(DOWNLOAD_FILE)
157 }
158
159 pub fn create_download_file(&self) -> Result<std::fs::File, PinnedStoreError> {
161 use std::os::unix::fs::OpenOptionsExt;
162 std::fs::OpenOptions::new()
163 .write(true)
164 .create_new(true)
165 .mode(crate::murmur_home::PRIVATE_FILE_MODE)
166 .open(self.download_path())
167 .map_err(|err| store_error(&self.download_path(), err))
168 }
169}
170
171impl Drop for PartialDownload {
172 fn drop(&mut self) {
173 if !self.kept {
174 let _ = std::fs::remove_dir_all(&self.dir);
175 }
176 }
177}
178
179#[derive(Debug, Clone, PartialEq, Eq)]
181pub struct CommittedEntry {
182 pub entry: PathBuf,
184 pub program_path: PathBuf,
186 pub raced: bool,
188}
189
190pub fn commit_download(
201 store_root: &Path,
202 download: &PinnedDownload,
203 program: &str,
204 partial: PartialDownload,
205) -> Result<CommittedEntry, PinnedStoreError> {
206 commit_download_capped(
207 store_root,
208 download,
209 program,
210 partial,
211 MAX_PINNED_DOWNLOAD_BYTES,
212 )
213}
214
215pub(crate) fn commit_download_capped(
216 store_root: &Path,
217 download: &PinnedDownload,
218 program: &str,
219 mut partial: PartialDownload,
220 cap: u64,
221) -> Result<CommittedEntry, PinnedStoreError> {
222 let entry = entry_path(store_root, &download.sha256)?;
223 let files = partial.dir.join(FILES_DIR);
224 create_private_dir(&files)?;
225 let downloaded = partial.download_path();
226 let program_file = match &download.run {
227 Some(run) => {
228 unpack_zip(&downloaded, &files, cap)?;
229 let target = files.join(run);
230 match std::fs::symlink_metadata(&target) {
231 Ok(metadata) if metadata.is_file() => {}
232 Ok(_) => {
233 return Err(PinnedStoreError::Unpack(format!(
234 "run names {run}, which is not a regular file in the zip"
235 )))
236 }
237 Err(_) => {
238 return Err(PinnedStoreError::Unpack(format!(
239 "run names {run}, and the zip holds no such file"
240 )))
241 }
242 }
243 std::fs::remove_file(&downloaded).map_err(|err| store_error(&downloaded, err))?;
244 run.clone()
245 }
246 None => {
247 let size = std::fs::metadata(&downloaded)
248 .map_err(|err| store_error(&downloaded, err))?
249 .len();
250 if size > cap {
251 return Err(PinnedStoreError::Unpack(format!(
252 "the download is {size} bytes, over the {cap}-byte cap"
253 )));
254 }
255 let name = single_file_name(&download.url);
256 let target = files.join(&name);
257 std::fs::rename(&downloaded, &target).map_err(|err| store_error(&target, err))?;
258 set_mode(&target, EXECUTABLE_FILE_MODE)?;
259 name
260 }
261 };
262 let tree = list_tree(&files).map_err(PinnedStoreError::Store)?;
263 link_program(&partial.dir, program, &program_file)?;
264 let record = PinnedRecord {
265 url: download.url.clone(),
266 sha256: download.sha256.clone(),
267 platform: download.platform.clone(),
268 run: download.run.clone(),
269 tree_sha256: tree_digest(&tree),
270 fetched_at: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
271 files: tree,
272 };
273 write_record(&partial.dir, &record)?;
274
275 match std::fs::rename(&partial.dir, &entry) {
276 Ok(()) => {
277 partial.kept = true;
278 let program_path = std::fs::canonicalize(entry.join(FILES_DIR).join(&program_file))
279 .map_err(|err| store_error(&entry, err))?;
280 Ok(CommittedEntry {
281 entry,
282 program_path,
283 raced: false,
284 })
285 }
286 Err(err) if entry.is_dir() => {
287 drop(partial);
288 match verify_entry(store_root, download) {
289 EntryState::Present { path, .. } => {
290 ensure_bin_link(store_root, download, program)?;
291 Ok(CommittedEntry {
292 entry,
293 program_path: path,
294 raced: true,
295 })
296 }
297 EntryState::Missing => Err(store_error(&entry, err)),
298 EntryState::ChecksumFailed { reason } => Err(PinnedStoreError::Store(format!(
299 "{} was stored by another install and fails its checksum: {reason}",
300 entry.display()
301 ))),
302 }
303 }
304 Err(err) => Err(store_error(&entry, err)),
305 }
306}
307
308#[derive(Debug, Clone, PartialEq, Eq)]
310pub enum EntryState {
311 Present {
313 path: PathBuf,
315 record: PinnedRecord,
316 },
317 Missing,
319 ChecksumFailed { reason: String },
322}
323
324pub fn verify_entry(store_root: &Path, download: &PinnedDownload) -> EntryState {
327 let Ok(entry) = entry_path(store_root, &download.sha256) else {
328 return EntryState::Missing;
329 };
330 if std::fs::symlink_metadata(&entry).is_err() {
331 return EntryState::Missing;
332 }
333 let failed = |reason: String| EntryState::ChecksumFailed { reason };
334 let record = match read_record(&entry) {
335 Ok(record) => record,
336 Err(reason) => return failed(reason),
337 };
338 if record.sha256 != download.sha256 {
339 return failed(format!(
340 "{RECORD_FILE} names sha256 {}, not the pinned {}",
341 record.sha256, download.sha256
342 ));
343 }
344 let tree = match list_tree(&entry.join(FILES_DIR)) {
345 Ok(tree) => tree,
346 Err(reason) => return failed(reason),
347 };
348 if let Some(reason) = tree_difference(&record.files, &tree) {
349 return failed(reason);
350 }
351 if tree_digest(&tree) != record.tree_sha256 {
352 return failed(format!(
353 "{RECORD_FILE}'s tree_sha256 does not match the files it lists"
354 ));
355 }
356 let Some(program_file) = record.program_file() else {
357 return failed(format!("{RECORD_FILE} names no program file"));
358 };
359 match std::fs::canonicalize(entry.join(FILES_DIR).join(program_file)) {
360 Ok(path) => EntryState::Present { path, record },
361 Err(_) => failed(format!(
362 "{FILES_DIR}/{program_file} is missing since install"
363 )),
364 }
365}
366
367pub fn ensure_bin_link(
370 store_root: &Path,
371 download: &PinnedDownload,
372 program: &str,
373) -> Result<bool, PinnedStoreError> {
374 let entry = entry_path(store_root, &download.sha256)?;
375 let record = read_record(&entry).map_err(PinnedStoreError::Store)?;
376 let program_file = record
377 .program_file()
378 .ok_or_else(|| PinnedStoreError::Store(format!("{RECORD_FILE} names no program file")))?
379 .to_string();
380 let link = entry.join(BIN_DIR).join(program);
381 if std::fs::read_link(&link).ok() == Some(link_target(&program_file)) {
382 return Ok(false);
383 }
384 if std::fs::symlink_metadata(&link).is_ok() {
385 std::fs::remove_file(&link).map_err(|err| store_error(&link, err))?;
386 }
387 link_program(&entry, program, &program_file)?;
388 Ok(true)
389}
390
391pub fn remove_entry(store_root: &Path, sha256: &str) -> Result<(), PinnedStoreError> {
393 let entry = entry_path(store_root, sha256)?;
394 match std::fs::remove_dir_all(&entry) {
395 Ok(()) => Ok(()),
396 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
397 Err(err) => Err(store_error(&entry, err)),
398 }
399}
400
401pub fn read_record(entry: &Path) -> Result<PinnedRecord, String> {
403 let path = entry.join(RECORD_FILE);
404 let text = std::fs::read_to_string(&path)
405 .map_err(|err| format!("{RECORD_FILE} cannot be read: {err}"))?;
406 serde_json::from_str(&text).map_err(|err| format!("{RECORD_FILE} cannot be parsed: {err}"))
407}
408
409#[derive(Debug, Clone, PartialEq, Eq)]
411pub struct DeclaredToolDownloads {
412 pub artifact: String,
414 pub origin: LockOrigin,
415 pub programs: Vec<ProgramDownloads>,
417}
418
419#[derive(Debug, Clone, PartialEq, Eq)]
421pub enum PinnedSource {
422 Pinned {
425 path: PathBuf,
427 bin_dir: PathBuf,
429 tree: PathBuf,
431 url: String,
432 sha256: String,
433 },
434 Host { path: PathBuf },
436 Missing { reason: String },
438}
439
440impl PinnedSource {
441 pub fn label(&self) -> &'static str {
443 match self {
444 Self::Pinned { .. } => "pinned",
445 Self::Host { .. } => "host",
446 Self::Missing { .. } => "missing",
447 }
448 }
449}
450
451#[derive(Debug, Clone, PartialEq, Eq)]
453pub struct ResolvedPinnedProgram {
454 pub artifact: String,
455 pub program: String,
456 pub platform: String,
457 pub source: PinnedSource,
458}
459
460impl ResolvedPinnedProgram {
461 pub fn pinned_dirs(&self) -> Option<(&Path, &Path)> {
463 match &self.source {
464 PinnedSource::Pinned { bin_dir, tree, .. } => Some((bin_dir, tree)),
465 _ => None,
466 }
467 }
468}
469
470pub fn pinned_bin_dirs(programs: &[ResolvedPinnedProgram]) -> Vec<PathBuf> {
472 let mut dirs: Vec<PathBuf> = Vec::new();
473 for (bin_dir, _) in programs
474 .iter()
475 .filter_map(ResolvedPinnedProgram::pinned_dirs)
476 {
477 if !dirs.iter().any(|dir| dir == bin_dir) {
478 dirs.push(bin_dir.to_path_buf());
479 }
480 }
481 dirs
482}
483
484pub fn pinned_trees(programs: &[ResolvedPinnedProgram]) -> Vec<PathBuf> {
486 let mut trees: Vec<PathBuf> = Vec::new();
487 for (_, tree) in programs
488 .iter()
489 .filter_map(ResolvedPinnedProgram::pinned_dirs)
490 {
491 if !trees.iter().any(|known| known == tree) {
492 trees.push(tree.to_path_buf());
493 }
494 }
495 trees
496}
497
498pub fn derive_pinned_programs(
504 tools: &[DeclaredToolDownloads],
505 shell_allow: &[String],
506) -> Vec<ResolvedPinnedProgram> {
507 let store_root = program_store_root();
508 resolve_pinned_programs(
509 tools,
510 shell_allow,
511 &host_path_dirs(),
512 store_root.as_deref().map_err(String::as_str),
513 murmur_artifact::host_download_platform(),
514 )
515}
516
517pub fn host_path_dirs() -> Vec<PathBuf> {
519 std::env::var_os("PATH")
520 .map(|path| std::env::split_paths(&path).collect())
521 .unwrap_or_default()
522}
523
524pub fn resolve_pinned_programs(
533 tools: &[DeclaredToolDownloads],
534 shell_allow: &[String],
535 path_dirs: &[PathBuf],
536 store_root: Result<&Path, &str>,
537 platform: Option<&str>,
538) -> Vec<ResolvedPinnedProgram> {
539 let Some(platform) = platform else {
540 return Vec::new();
541 };
542 let mut resolved = Vec::new();
543 for (tool, program) in gated_programs(tools, shell_allow) {
544 let Some(download) = program.for_platform(platform) else {
545 continue;
546 };
547 let source = match host_program(&program.program, path_dirs) {
548 Some(path) => PinnedSource::Host { path },
549 None => stored_source(store_root, download, &program.program),
550 };
551 resolved.push(ResolvedPinnedProgram {
552 artifact: tool.artifact.clone(),
553 program: program.program.clone(),
554 platform: platform.to_string(),
555 source,
556 });
557 }
558 resolved
559}
560
561pub fn gated_programs<'a>(
565 tools: &'a [DeclaredToolDownloads],
566 shell_allow: &'a [String],
567) -> impl Iterator<Item = (&'a DeclaredToolDownloads, &'a ProgramDownloads)> + 'a {
568 tools
569 .iter()
570 .filter(|tool| tool.origin == LockOrigin::Operator)
571 .flat_map(move |tool| {
572 tool.programs
573 .iter()
574 .filter(move |program| shell_allow.contains(&program.program))
575 .map(move |program| (tool, program))
576 })
577}
578
579pub fn host_program(program: &str, path_dirs: &[PathBuf]) -> Option<PathBuf> {
581 path_dirs
582 .iter()
583 .map(|dir| dir.join(program))
584 .find(|candidate| crate::sandbox::is_executable_file(candidate))
585 .and_then(|candidate| std::fs::canonicalize(candidate).ok())
586}
587
588fn stored_source(
591 store_root: Result<&Path, &str>,
592 download: &PinnedDownload,
593 program: &str,
594) -> PinnedSource {
595 let missing = |reason: String| PinnedSource::Missing { reason };
596 let sha256 = &download.sha256;
597 let store_root = match store_root {
598 Ok(root) => root,
599 Err(reason) => {
600 return missing(format!("the program store cannot be located: {reason}"));
601 }
602 };
603 let Ok(entry) = entry_path(store_root, sha256) else {
604 return missing(format!("sha256 {sha256} cannot name a store entry"));
605 };
606 let Ok(tree) = std::fs::canonicalize(&entry) else {
607 return missing(format!(
608 "the store holds no copy for sha256 {sha256}; run mur install"
609 ));
610 };
611 let record = match read_record(&tree) {
612 Ok(record) => record,
613 Err(reason) => {
614 return missing(format!(
615 "the store's copy for sha256 {sha256} has no usable record ({reason}); run mur \
616 install"
617 ))
618 }
619 };
620 if record.sha256 != *sha256 {
621 return missing(format!(
622 "the store's record for sha256 {sha256} names sha256 {}; run mur install",
623 record.sha256
624 ));
625 }
626 let link = tree.join(BIN_DIR).join(program);
627 let runnable = std::fs::canonicalize(&link)
628 .ok()
629 .filter(|path| path.starts_with(tree.join(FILES_DIR)))
630 .filter(|path| crate::sandbox::is_executable_file(path));
631 match runnable {
632 Some(path) => PinnedSource::Pinned {
633 path,
634 bin_dir: tree.join(BIN_DIR),
635 tree,
636 url: record.url,
637 sha256: sha256.clone(),
638 },
639 None => missing(format!(
640 "the store's copy for sha256 {sha256} has no runnable {BIN_DIR}/{program}; run mur \
641 install"
642 )),
643 }
644}
645
646fn entry_path(store_root: &Path, sha256: &str) -> Result<PathBuf, PinnedStoreError> {
648 if !murmur_artifact::is_sha256_hex(sha256) {
649 return Err(PinnedStoreError::Store(format!(
650 "'{sha256}' is not a sha256 the store names an entry by"
651 )));
652 }
653 Ok(store_root.join(sha256))
654}
655
656fn single_file_name(url: &str) -> String {
659 url::Url::parse(url)
660 .ok()
661 .and_then(|url| {
662 url.path_segments()
663 .and_then(|mut segments| segments.next_back().map(str::to_string))
664 })
665 .filter(|name| {
666 !name.is_empty() && name != "." && name != ".." && !name.contains(['/', '\\', '\0'])
667 })
668 .unwrap_or_else(|| DOWNLOAD_FILE.to_string())
669}
670
671fn link_target(program_file: &str) -> PathBuf {
672 Path::new("..").join(FILES_DIR).join(program_file)
673}
674
675fn link_program(entry: &Path, program: &str, program_file: &str) -> Result<(), PinnedStoreError> {
677 let bin = entry.join(BIN_DIR);
678 if !bin.is_dir() {
679 create_private_dir(&bin)?;
680 }
681 let link = bin.join(program);
682 std::os::unix::fs::symlink(link_target(program_file), &link)
683 .map_err(|err| store_error(&link, err))
684}
685
686fn write_record(entry: &Path, record: &PinnedRecord) -> Result<(), PinnedStoreError> {
687 use std::os::unix::fs::OpenOptionsExt;
688 let path = entry.join(RECORD_FILE);
689 let text = serde_json::to_string_pretty(record)
690 .map_err(|err| PinnedStoreError::Store(format!("{RECORD_FILE}: {err}")))?;
691 let mut file = std::fs::OpenOptions::new()
692 .write(true)
693 .create_new(true)
694 .mode(crate::murmur_home::PRIVATE_FILE_MODE)
695 .open(&path)
696 .map_err(|err| store_error(&path, err))?;
697 file.write_all(text.as_bytes())
698 .and_then(|()| file.sync_all())
699 .map_err(|err| store_error(&path, err))
700}
701
702fn unpack_zip(zip_path: &Path, into: &Path, cap: u64) -> Result<(), PinnedStoreError> {
704 let unpack = PinnedStoreError::Unpack;
705 let file = std::fs::File::open(zip_path).map_err(|err| store_error(zip_path, err))?;
706 let mut archive = zip::ZipArchive::new(file)
707 .map_err(|err| unpack(format!("the download is no zip: {err}")))?;
708 let mut total: u64 = 0;
709 for index in 0..archive.len() {
710 let mut entry = archive
711 .by_index(index)
712 .map_err(|err| unpack(format!("zip entry {index} cannot be read: {err}")))?;
713 let name = entry
714 .name()
715 .map_err(|err| unpack(format!("zip entry {index} has no readable name: {err}")))?
716 .to_string();
717 if entry.is_symlink() {
718 return Err(unpack(format!(
719 "zip entry '{name}' is a symlink; a pinned download unpacks only files and folders"
720 )));
721 }
722 if name.starts_with('/') || name.contains('\\') {
723 return Err(unpack(format!(
724 "zip entry '{name}' escapes the archive's folder"
725 )));
726 }
727 let relative = murmur_artifact::zip_guard::normalized_entry_name(&name)
728 .map_err(|_| unpack(format!("zip entry '{name}' escapes the archive's folder")))?;
729 if relative.is_empty() {
730 continue;
731 }
732 let target = into.join(&relative);
733 if entry.is_dir() {
734 create_private_dirs(into, &target)?;
735 continue;
736 }
737 if let Some(parent) = target.parent() {
738 create_private_dirs(into, parent)?;
739 }
740 let executable = entry.unix_mode().is_some_and(|mode| mode & 0o111 != 0);
741 let mode = if executable {
742 EXECUTABLE_FILE_MODE
743 } else {
744 crate::murmur_home::PRIVATE_FILE_MODE
745 };
746 let mut out = {
747 use std::os::unix::fs::OpenOptionsExt;
748 std::fs::OpenOptions::new()
749 .write(true)
750 .create_new(true)
751 .mode(mode)
752 .open(&target)
753 .map_err(|err| match err.kind() {
754 std::io::ErrorKind::AlreadyExists => {
755 unpack(format!("zip entry '{name}' appears more than once"))
756 }
757 _ => store_error(&target, err),
758 })?
759 };
760 let remaining = cap.saturating_sub(total);
761 let written = std::io::copy(&mut (&mut entry).take(remaining + 1), &mut out)
762 .map_err(|err| unpack(format!("zip entry '{name}' cannot be unpacked: {err}")))?;
763 total += written;
764 if total > cap {
765 return Err(unpack(format!(
766 "the unpacked tree is over the {cap}-byte cap"
767 )));
768 }
769 set_mode(&target, mode)?;
770 }
771 Ok(())
772}
773
774fn create_private_dirs(root: &Path, dir: &Path) -> Result<(), PinnedStoreError> {
776 let Ok(relative) = dir.strip_prefix(root) else {
777 return Err(PinnedStoreError::Unpack(format!(
778 "{} escapes the archive's folder",
779 dir.display()
780 )));
781 };
782 let mut current = root.to_path_buf();
783 for component in relative.components() {
784 current.push(component);
785 match std::fs::symlink_metadata(¤t) {
786 Ok(metadata) if metadata.is_dir() => {}
787 Ok(_) => {
788 return Err(PinnedStoreError::Unpack(format!(
789 "zip entry folder {} is also a file",
790 current.display()
791 )))
792 }
793 Err(_) => create_private_dir(¤t)?,
794 }
795 }
796 Ok(())
797}
798
799fn create_private_dir(dir: &Path) -> Result<(), PinnedStoreError> {
800 use std::os::unix::fs::DirBuilderExt;
801 std::fs::DirBuilder::new()
802 .mode(crate::murmur_home::MURMUR_HOME_DIR_MODE)
803 .create(dir)
804 .map_err(|err| store_error(dir, err))?;
805 set_mode(dir, crate::murmur_home::MURMUR_HOME_DIR_MODE)
806}
807
808fn set_mode(path: &Path, mode: u32) -> Result<(), PinnedStoreError> {
809 use std::os::unix::fs::PermissionsExt;
810 std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))
811 .map_err(|err| store_error(path, err))
812}
813
814fn list_tree(files: &Path) -> Result<Vec<TreeFile>, String> {
816 let mut found = Vec::new();
817 walk(files, files, &mut found)?;
818 found.sort();
819 Ok(found)
820}
821
822fn walk(root: &Path, dir: &Path, found: &mut Vec<TreeFile>) -> Result<(), String> {
823 use std::os::unix::fs::PermissionsExt;
824 let entries = std::fs::read_dir(dir)
825 .map_err(|err| format!("{} cannot be read: {err}", shown(root, dir)))?;
826 for entry in entries {
827 let path = entry.map_err(|err| err.to_string())?.path();
828 let metadata = std::fs::symlink_metadata(&path).map_err(|err| err.to_string())?;
829 if metadata.is_dir() {
830 walk(root, &path, found)?;
831 } else if metadata.is_file() {
832 found.push(TreeFile {
833 path: relative_name(root, &path),
834 executable: metadata.permissions().mode() & 0o100 != 0,
835 sha256: file_sha256(&path)
836 .map_err(|err| format!("{} cannot be read: {err}", shown(root, &path)))?,
837 });
838 } else {
839 return Err(format!(
840 "{} is not a regular file or folder",
841 shown(root, &path)
842 ));
843 }
844 }
845 Ok(())
846}
847
848fn tree_difference(recorded: &[TreeFile], actual: &[TreeFile]) -> Option<String> {
850 for file in recorded {
851 match actual.iter().find(|other| other.path == file.path) {
852 None => {
853 return Some(format!(
854 "{FILES_DIR}/{} is missing since install",
855 file.path
856 ))
857 }
858 Some(other) if other != file => {
859 return Some(format!("{FILES_DIR}/{} changed since install", file.path))
860 }
861 Some(_) => {}
862 }
863 }
864 actual
865 .iter()
866 .find(|file| !recorded.iter().any(|other| other.path == file.path))
867 .map(|file| format!("{FILES_DIR}/{} was added since install", file.path))
868}
869
870fn relative_name(root: &Path, path: &Path) -> String {
871 path.strip_prefix(root)
872 .unwrap_or(path)
873 .components()
874 .map(|component| component.as_os_str().to_string_lossy().into_owned())
875 .collect::<Vec<_>>()
876 .join("/")
877}
878
879fn shown(root: &Path, path: &Path) -> String {
880 let relative = relative_name(root, path);
881 if relative.is_empty() {
882 FILES_DIR.to_string()
883 } else {
884 format!("{FILES_DIR}/{relative}")
885 }
886}
887
888fn file_sha256(path: &Path) -> std::io::Result<String> {
890 let mut file = std::fs::File::open(path)?;
891 murmur_artifact::sha256_hex_of_reader(&mut file).map(|(_, digest)| digest)
892}
893
894fn store_error(path: &Path, err: std::io::Error) -> PinnedStoreError {
895 PinnedStoreError::Store(format!("{}: {err}", path.display()))
896}
897
898#[cfg(test)]
899mod tests {
900 use super::*;
901 use std::os::unix::fs::PermissionsExt;
902
903 const PROGRAM: &str = "fixture-prog";
904
905 fn zip_of(entries: &[(&str, u32, &[u8])]) -> Vec<u8> {
907 let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
908 for (name, mode, contents) in entries {
909 if mode & 0o170000 == 0o120000 {
910 writer
911 .add_symlink(
912 *name,
913 std::str::from_utf8(contents).unwrap(),
914 zip::write::SimpleFileOptions::default(),
915 )
916 .unwrap();
917 continue;
918 }
919 let options = zip::write::SimpleFileOptions::default().unix_permissions(*mode);
920 writer.start_file(*name, options).unwrap();
921 writer.write_all(contents).unwrap();
922 }
923 writer.finish().unwrap().into_inner()
924 }
925
926 fn fixture_zip() -> Vec<u8> {
927 zip_of(&[
928 (
929 "fixture/fixture-prog",
930 0o755,
931 b"#!/bin/sh\necho pinned-copy-ran\n",
932 ),
933 ("fixture/data.txt", 0o644, b"data\n"),
934 ])
935 }
936
937 fn sha_of(bytes: &[u8]) -> String {
938 murmur_artifact::sha256_hex(bytes)
939 }
940
941 fn download_for(bytes: &[u8], run: Option<&str>) -> PinnedDownload {
942 PinnedDownload {
943 platform: "linux-x86_64".to_string(),
944 url: "http://127.0.0.1:1/fixture-prog.zip".to_string(),
945 sha256: sha_of(bytes),
946 run: run.map(str::to_string),
947 }
948 }
949
950 fn partial_with(store: &Path, bytes: &[u8]) -> PartialDownload {
951 let partial = PartialDownload::begin(store).unwrap();
952 partial
953 .create_download_file()
954 .unwrap()
955 .write_all(bytes)
956 .unwrap();
957 partial
958 }
959
960 fn commit(
961 store: &Path,
962 bytes: &[u8],
963 run: Option<&str>,
964 ) -> Result<CommittedEntry, PinnedStoreError> {
965 commit_download(
966 store,
967 &download_for(bytes, run),
968 PROGRAM,
969 partial_with(store, bytes),
970 )
971 }
972
973 fn mode_of(path: &Path) -> u32 {
974 std::fs::symlink_metadata(path)
975 .unwrap()
976 .permissions()
977 .mode()
978 & 0o777
979 }
980
981 fn no_partials(store: &Path) -> bool {
982 std::fs::read_dir(store).unwrap().all(|entry| {
983 !entry
984 .unwrap()
985 .file_name()
986 .to_string_lossy()
987 .starts_with(PARTIAL_PREFIX)
988 })
989 }
990
991 #[test]
992 fn pinned_programs_commit_unpacks_links_records_and_strips_modes() {
993 let dir = tempfile::tempdir().unwrap();
994 let store = dir.path();
995 let bytes = fixture_zip();
996 let committed = commit(store, &bytes, Some("fixture/fixture-prog")).unwrap();
997 let entry = store.join(sha_of(&bytes));
998 assert_eq!(committed.entry, entry);
999 assert!(!committed.raced);
1000 assert_eq!(
1001 committed.program_path,
1002 std::fs::canonicalize(entry.join("files/fixture/fixture-prog")).unwrap()
1003 );
1004 assert_eq!(mode_of(&entry), 0o700);
1005 assert_eq!(mode_of(&entry.join("files")), 0o700);
1006 assert_eq!(mode_of(&entry.join("files/fixture")), 0o700);
1007 assert_eq!(mode_of(&entry.join("files/fixture/fixture-prog")), 0o700);
1008 assert_eq!(mode_of(&entry.join("files/fixture/data.txt")), 0o600);
1009 assert_eq!(mode_of(&entry.join("record.json")), 0o600);
1010 assert!(!entry.join(DOWNLOAD_FILE).exists());
1011 assert_eq!(
1012 std::fs::read_link(entry.join("bin/fixture-prog")).unwrap(),
1013 PathBuf::from("../files/fixture/fixture-prog")
1014 );
1015 let record = read_record(&entry).unwrap();
1016 assert_eq!(record.sha256, sha_of(&bytes));
1017 assert_eq!(record.run.as_deref(), Some("fixture/fixture-prog"));
1018 assert_eq!(record.platform, "linux-x86_64");
1019 assert_eq!(record.files.len(), 2);
1020 assert_eq!(record.tree_sha256, tree_digest(&record.files));
1021 let lines = format!(
1022 "fixture/data.txt\t-\t{}\nfixture/fixture-prog\tx\t{}\n",
1023 sha_of(b"data\n"),
1024 sha_of(b"#!/bin/sh\necho pinned-copy-ran\n")
1025 );
1026 assert_eq!(record.tree_sha256, sha_of(lines.as_bytes()));
1027 assert!(no_partials(store));
1028 assert!(matches!(
1029 verify_entry(store, &download_for(&bytes, Some("fixture/fixture-prog"))),
1030 EntryState::Present { .. }
1031 ));
1032 }
1033
1034 #[test]
1035 fn pinned_programs_a_single_file_is_stored_executable_under_the_url_name() {
1036 let dir = tempfile::tempdir().unwrap();
1037 let store = dir.path();
1038 let bytes = b"#!/bin/sh\necho single\n";
1039 let committed = commit(store, bytes, None).unwrap();
1040 let entry = store.join(sha_of(bytes));
1041 assert_eq!(
1042 committed.program_path,
1043 std::fs::canonicalize(entry.join("files/fixture-prog.zip")).unwrap()
1044 );
1045 assert_eq!(mode_of(&entry.join("files/fixture-prog.zip")), 0o700);
1046 assert_eq!(
1047 std::fs::read_link(entry.join("bin/fixture-prog")).unwrap(),
1048 PathBuf::from("../files/fixture-prog.zip")
1049 );
1050 assert_eq!(single_file_name("https://h.example/a/b/tool?x=1"), "tool");
1051 assert_eq!(single_file_name("https://h.example/"), "download");
1052 }
1053
1054 #[test]
1055 fn pinned_programs_commit_refuses_a_symlink_an_escape_a_missing_run_and_the_cap() {
1056 let dir = tempfile::tempdir().unwrap();
1057 let store = dir.path();
1058 let cases: Vec<(Vec<u8>, &str, &str)> = vec![
1059 (
1060 zip_of(&[
1061 ("fixture/fixture-prog", 0o755, b"x"),
1062 ("fixture/link", 0o120777, b"/etc/passwd"),
1063 ]),
1064 "fixture/fixture-prog",
1065 "zip entry 'fixture/link' is a symlink",
1066 ),
1067 (
1068 zip_of(&[
1069 ("../escape", 0o644, b"x"),
1070 ("fixture/fixture-prog", 0o755, b"x"),
1071 ]),
1072 "fixture/fixture-prog",
1073 "zip entry '../escape' escapes the archive's folder",
1074 ),
1075 (
1076 zip_of(&[("fixture/fixture-prog", 0o755, b"x")]),
1077 "fixture/absent",
1078 "run names fixture/absent, and the zip holds no such file",
1079 ),
1080 (
1081 zip_of(&[("fixture/fixture-prog/", 0o755, b"")]),
1082 "fixture/fixture-prog",
1083 "run names fixture/fixture-prog, which is not a regular file in the zip",
1084 ),
1085 (
1086 zip_of(&[
1087 ("fixture/fixture-prog", 0o755, b"x"),
1088 ("./fixture/fixture-prog", 0o755, b"y"),
1089 ]),
1090 "fixture/fixture-prog",
1091 "zip entry './fixture/fixture-prog' appears more than once",
1092 ),
1093 (
1094 b"not a zip".to_vec(),
1095 "fixture/fixture-prog",
1096 "the download is no zip",
1097 ),
1098 ];
1099 for (bytes, run, expected) in cases {
1100 let err = commit(store, &bytes, Some(run)).unwrap_err();
1101 assert!(matches!(err, PinnedStoreError::Unpack(_)), "{err:?}");
1102 assert!(err.to_string().contains(expected), "{err}");
1103 assert!(!store.join(sha_of(&bytes)).exists());
1104 assert!(no_partials(store));
1105 }
1106 let bytes = zip_of(&[("fixture/fixture-prog", 0o755, &[7u8; 64])]);
1107 let err = commit_download_capped(
1108 store,
1109 &download_for(&bytes, Some("fixture/fixture-prog")),
1110 PROGRAM,
1111 partial_with(store, &bytes),
1112 32,
1113 )
1114 .unwrap_err();
1115 assert_eq!(
1116 err,
1117 PinnedStoreError::Unpack("the unpacked tree is over the 32-byte cap".to_string())
1118 );
1119 assert!(no_partials(store));
1120 }
1121
1122 #[test]
1123 fn pinned_programs_verify_names_a_changed_missing_or_added_file() {
1124 let dir = tempfile::tempdir().unwrap();
1125 let store = dir.path();
1126 let bytes = fixture_zip();
1127 let download = download_for(&bytes, Some("fixture/fixture-prog"));
1128 assert_eq!(verify_entry(store, &download), EntryState::Missing);
1129 commit(store, &bytes, Some("fixture/fixture-prog")).unwrap();
1130 let entry = store.join(&download.sha256);
1131
1132 std::fs::write(entry.join("files/fixture/data.txt"), "changed\n").unwrap();
1133 assert_eq!(
1134 verify_entry(store, &download),
1135 EntryState::ChecksumFailed {
1136 reason: "files/fixture/data.txt changed since install".to_string()
1137 }
1138 );
1139 std::fs::write(entry.join("files/fixture/data.txt"), "data\n").unwrap();
1140 assert!(matches!(
1141 verify_entry(store, &download),
1142 EntryState::Present { .. }
1143 ));
1144
1145 std::fs::set_permissions(
1146 entry.join("files/fixture/data.txt"),
1147 std::fs::Permissions::from_mode(0o700),
1148 )
1149 .unwrap();
1150 assert_eq!(
1151 verify_entry(store, &download),
1152 EntryState::ChecksumFailed {
1153 reason: "files/fixture/data.txt changed since install".to_string()
1154 }
1155 );
1156 std::fs::remove_file(entry.join("files/fixture/data.txt")).unwrap();
1157 assert_eq!(
1158 verify_entry(store, &download),
1159 EntryState::ChecksumFailed {
1160 reason: "files/fixture/data.txt is missing since install".to_string()
1161 }
1162 );
1163 std::fs::write(entry.join("files/fixture/data.txt"), "data\n").unwrap();
1164 std::fs::set_permissions(
1165 entry.join("files/fixture/data.txt"),
1166 std::fs::Permissions::from_mode(0o600),
1167 )
1168 .unwrap();
1169 std::fs::write(entry.join("files/extra"), "x").unwrap();
1170 assert_eq!(
1171 verify_entry(store, &download),
1172 EntryState::ChecksumFailed {
1173 reason: "files/extra was added since install".to_string()
1174 }
1175 );
1176 std::fs::remove_file(entry.join("files/extra")).unwrap();
1177 std::fs::write(entry.join("record.json"), "{").unwrap();
1178 assert!(matches!(
1179 verify_entry(store, &download),
1180 EntryState::ChecksumFailed { reason } if reason.starts_with("record.json cannot be parsed")
1181 ));
1182 }
1183
1184 #[test]
1185 fn pinned_programs_a_concurrent_commit_keeps_theirs_and_links_ours() {
1186 let dir = tempfile::tempdir().unwrap();
1187 let store = dir.path();
1188 let bytes = fixture_zip();
1189 let download = download_for(&bytes, Some("fixture/fixture-prog"));
1190 let ours = partial_with(store, &bytes);
1191 commit(store, &bytes, Some("fixture/fixture-prog")).unwrap();
1192 let committed = commit_download(store, &download, "other-name", ours).unwrap();
1193 assert!(committed.raced);
1194 let entry = store.join(&download.sha256);
1195 assert_eq!(
1196 std::fs::read_link(entry.join("bin/other-name")).unwrap(),
1197 PathBuf::from("../files/fixture/fixture-prog")
1198 );
1199 assert!(no_partials(store));
1200 assert!(!ensure_bin_link(store, &download, "other-name").unwrap());
1201 std::fs::remove_file(entry.join("bin/fixture-prog")).unwrap();
1202 assert!(ensure_bin_link(store, &download, PROGRAM).unwrap());
1203 remove_entry(store, &download.sha256).unwrap();
1204 assert!(!entry.exists());
1205 remove_entry(store, &download.sha256).unwrap();
1206 assert!(remove_entry(store, "../x").is_err());
1207 }
1208
1209 fn tool(origin: LockOrigin, download: &PinnedDownload, program: &str) -> DeclaredToolDownloads {
1210 DeclaredToolDownloads {
1211 artifact: "local/pinned-tool".to_string(),
1212 origin,
1213 programs: vec![ProgramDownloads {
1214 program: program.to_string(),
1215 downloads: vec![download.clone()],
1216 }],
1217 }
1218 }
1219
1220 fn allow(names: &[&str]) -> Vec<String> {
1221 names.iter().map(|name| (*name).to_string()).collect()
1222 }
1223
1224 #[test]
1225 fn pinned_programs_resolve_follows_the_gate_host_first_then_the_store() {
1226 let dir = tempfile::tempdir().unwrap();
1227 let store = dir.path().join("programs");
1228 std::fs::create_dir(&store).unwrap();
1229 let bytes = fixture_zip();
1230 let download = download_for(&bytes, Some("fixture/fixture-prog"));
1231 let empty_path: Vec<PathBuf> = vec![dir.path().join("no-such-dir")];
1232 let resolve = |tools: &[DeclaredToolDownloads], allowed: &[&str], path: &[PathBuf]| {
1233 resolve_pinned_programs(
1234 tools,
1235 &allow(allowed),
1236 path,
1237 Ok(&store),
1238 Some("linux-x86_64"),
1239 )
1240 };
1241 let operator = [tool(LockOrigin::Operator, &download, PROGRAM)];
1242
1243 let missing = resolve(&operator, &["bash", PROGRAM], &empty_path);
1245 assert_eq!(missing.len(), 1);
1246 assert_eq!(
1247 missing[0].source,
1248 PinnedSource::Missing {
1249 reason: format!(
1250 "the store holds no copy for sha256 {}; run mur install",
1251 download.sha256
1252 )
1253 }
1254 );
1255
1256 commit(&store, &bytes, Some("fixture/fixture-prog")).unwrap();
1257 let tree = std::fs::canonicalize(store.join(&download.sha256)).unwrap();
1258 let pinned = resolve(&operator, &[PROGRAM], &empty_path);
1259 assert_eq!(
1260 pinned[0].source,
1261 PinnedSource::Pinned {
1262 path: tree.join("files/fixture/fixture-prog"),
1263 bin_dir: tree.join("bin"),
1264 tree: tree.clone(),
1265 url: download.url.clone(),
1266 sha256: download.sha256.clone(),
1267 }
1268 );
1269 assert_eq!(pinned_bin_dirs(&pinned), vec![tree.join("bin")]);
1270 assert_eq!(pinned_trees(&pinned), vec![tree.clone()]);
1271
1272 assert!(resolve(&operator, &["bash"], &empty_path).is_empty());
1274 assert!(resolve(&operator, &["Fixture-prog"], &empty_path).is_empty());
1275 let runtime = [tool(
1276 LockOrigin::Runtime {
1277 session: "ses_x".to_string(),
1278 },
1279 &download,
1280 PROGRAM,
1281 )];
1282 assert!(resolve(&runtime, &[PROGRAM], &empty_path).is_empty());
1283 assert!(resolve_pinned_programs(
1284 &operator,
1285 &allow(&[PROGRAM]),
1286 &empty_path,
1287 Ok(&store),
1288 Some("darwin-aarch64"),
1289 )
1290 .is_empty());
1291 assert!(resolve_pinned_programs(
1292 &operator,
1293 &allow(&[PROGRAM]),
1294 &empty_path,
1295 Ok(&store),
1296 None
1297 )
1298 .is_empty());
1299
1300 let host_dir = dir.path().join("host-bin");
1302 std::fs::create_dir(&host_dir).unwrap();
1303 let host = host_dir.join(PROGRAM);
1304 std::fs::write(&host, "#!/bin/sh\necho host-copy-ran\n").unwrap();
1305 std::fs::set_permissions(&host, std::fs::Permissions::from_mode(0o755)).unwrap();
1306 let chosen = resolve(&operator, &[PROGRAM], std::slice::from_ref(&host_dir));
1307 assert_eq!(
1308 chosen[0].source,
1309 PinnedSource::Host {
1310 path: std::fs::canonicalize(&host).unwrap()
1311 }
1312 );
1313 assert!(pinned_bin_dirs(&chosen).is_empty());
1314
1315 let record_path = tree.join(RECORD_FILE);
1317 let record_text = std::fs::read_to_string(&record_path).unwrap();
1318 std::fs::write(
1319 &record_path,
1320 record_text.replace(&download.sha256, &"ab".repeat(32)),
1321 )
1322 .unwrap();
1323 assert!(matches!(
1324 &resolve(&operator, &[PROGRAM], &empty_path)[0].source,
1325 PinnedSource::Missing { reason } if reason.contains(&format!("names sha256 {}", "ab".repeat(32)))
1326 ));
1327 std::fs::write(&record_path, record_text).unwrap();
1328 std::fs::remove_file(tree.join("bin").join(PROGRAM)).unwrap();
1329 assert!(matches!(
1330 &resolve(&operator, &[PROGRAM], &empty_path)[0].source,
1331 PinnedSource::Missing { reason } if reason.contains("has no runnable bin/fixture-prog")
1332 ));
1333 let store_err = resolve_pinned_programs(
1334 &operator,
1335 &allow(&[PROGRAM]),
1336 &empty_path,
1337 Err("HOME is not set"),
1338 Some("linux-x86_64"),
1339 );
1340 assert!(matches!(
1341 &store_err[0].source,
1342 PinnedSource::Missing { reason } if reason.contains("HOME is not set")
1343 ));
1344 }
1345}