swiftide_docker_executor/
context_builder.rs

1use std::{
2    os::unix::fs::MetadataExt as _,
3    path::{Path, PathBuf},
4};
5
6use ignore::gitignore::{Gitignore, GitignoreBuilder};
7// use ignore::{overrides::OverrideBuilder, WalkBuilder};
8use tokio::io::AsyncReadExt as _;
9use tokio_tar::{Builder, EntryType, Header};
10use walkdir::{DirEntry, WalkDir};
11
12use crate::ContextError;
13
14type ContextArchive = Vec<u8>;
15
16#[derive(Debug)]
17pub struct ContextBuilder {
18    context_path: PathBuf,
19    ignore: Gitignore,
20    global: Option<Gitignore>,
21}
22
23impl ContextBuilder {
24    pub fn from_path(context_path: impl Into<PathBuf>) -> Result<Self, ContextError> {
25        let path = context_path.into();
26        let mut gitignore = GitignoreBuilder::new(&path);
27
28        if let Some(err) = gitignore.add(path.join(".gitignore")) {
29            tracing::warn!(?err, "Error adding .gitignore");
30        }
31        if let Some(err) = gitignore.add(path.join(".dockerignore")) {
32            tracing::warn!(?err, "Error adding .dockerignore");
33        }
34
35        let gitignore = gitignore.build()?;
36
37        let (global_gitignore, maybe_error) = Gitignore::global();
38        let maybe_global = if let Some(err) = maybe_error {
39            tracing::warn!(?err, "Error adding global gitignore");
40            None
41        } else {
42            Some(global_gitignore)
43        };
44
45        Ok(Self {
46            context_path: path,
47            ignore: gitignore,
48            global: maybe_global,
49        })
50    }
51
52    fn is_ignored(&self, path: impl AsRef<Path>) -> bool {
53        let Ok(relative_path) = path.as_ref().strip_prefix(&self.context_path) else {
54            tracing::debug!(
55                "not ignoring {path} as it seems to be not prefixed by {prefix}",
56                path = path.as_ref().display(),
57                prefix = self.context_path.to_string_lossy()
58            );
59            return false;
60        };
61
62        if relative_path.starts_with(".git") {
63            tracing::debug!(
64                "not ignoring {path} as it seems to be a git file",
65                path = path.as_ref().display()
66            );
67            return false;
68        }
69
70        if let Some(global) = &self.global {
71            if global
72                .matched_path_or_any_parents(relative_path, false)
73                .is_ignore()
74            {
75                tracing::debug!(
76                    "ignoring {path} as it is ignored by global gitignore",
77                    path = path.as_ref().display()
78                );
79                return true;
80            }
81        }
82
83        self.ignore
84            .matched_path_or_any_parents(relative_path, false)
85            .is_ignore()
86    }
87
88    fn iter(&self) -> impl Iterator<Item = Result<DirEntry, walkdir::Error>> {
89        WalkDir::new(&self.context_path).into_iter()
90    }
91
92    pub async fn build_tar(&self) -> Result<ContextArchive, ContextError> {
93        let buffer = Vec::new();
94
95        let mut tar = Builder::new(buffer);
96
97        for entry in self.iter() {
98            let Ok(entry) = entry else {
99                tracing::warn!(?entry, "Failed to read entry");
100                continue;
101            };
102            let path = entry.path();
103
104            let Ok(relative_path) = path.strip_prefix(&self.context_path) else {
105                tracing::warn!(?path, "Failed to strip prefix on path");
106                continue;
107            };
108
109            if path.is_dir() && !path.is_symlink() {
110                tracing::debug!(path = ?path, relative_path = ?relative_path, "Adding directory to tar");
111                if let Err(err) = tar.append_path(relative_path).await {
112                    tracing::warn!(?err, "Failed to append path to tar");
113                }
114                continue;
115            }
116
117            if self.is_ignored(path) {
118                tracing::debug!(path = ?path, "Ignored file");
119                continue;
120            }
121
122            if path.is_symlink() {
123                tracing::debug!(path = ?path, "Adding symlink to tar");
124                let Ok(link_target) = tokio::fs::read_link(path).await else {
125                    continue;
126                }; // The target of the symlink
127                let Ok(metadata) = entry.metadata() else {
128                    continue;
129                };
130                tracing::debug!(link_target = ?link_target, "Symlink target");
131                let mut header = Header::new_gnu();
132
133                // Indicate it's a symlink
134                header.set_entry_type(EntryType::Symlink);
135                // The tar specification requires setting the link name for a symlink
136                header.set_link_name(link_target)?;
137
138                // Set ownership, permissions, etc.
139                header.set_uid(metadata.uid() as u64);
140                header.set_gid(metadata.gid() as u64);
141                // For a symlink, the "mode" is often ignored by many tools,
142                // but we’ll set it anyway to match the source:
143                header.set_mode(metadata.mode());
144                // Set modification time (use 0 or a real timestamp as you prefer)
145                header.set_mtime(metadata.mtime() as u64);
146                // Symlinks don’t store file data in the tar, so size is 0
147                header.set_size(0);
148
149                tar.append_data(&mut header, path, tokio::io::empty())
150                    .await?;
151                continue;
152            }
153
154            tracing::debug!(path = ?path, "Adding file to tar");
155            let mut file = tokio::fs::File::open(path).await?;
156            let mut buffer_content = Vec::new();
157            file.read_to_end(&mut buffer_content).await?;
158
159            let mut header = Header::new_gnu();
160            header.set_size(buffer_content.len() as u64);
161            header.set_mode(0o644);
162            header.set_cksum();
163
164            let relative_path = path.strip_prefix(&self.context_path)?;
165            tar.append_data(&mut header, relative_path, &*buffer_content)
166                .await?;
167        }
168
169        let result = tar.into_inner().await?;
170
171        Ok(result)
172    }
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178    use std::fs;
179    use std::io::Write;
180    use tempfile::tempdir;
181
182    #[test_log::test(tokio::test)]
183    async fn test_is_ignored() {
184        let dir = tempdir().unwrap();
185        let context_path = dir.path().to_path_buf();
186
187        // Create .gitignore file
188        let mut gitignore_file = fs::File::create(context_path.join(".gitignore")).unwrap();
189        writeln!(gitignore_file, "*.log").unwrap();
190
191        // Create .dockerignore file
192        let mut dockerignore_file = fs::File::create(context_path.join(".dockerignore")).unwrap();
193        writeln!(dockerignore_file, "*.tmp").unwrap();
194
195        dbg!(&std::fs::read_to_string(context_path.join(".gitignore")).unwrap());
196
197        let context_builder = ContextBuilder::from_path(&context_path).unwrap();
198
199        // Create test files
200        let log_file = context_path.join("test.log");
201        let tmp_file = context_path.join("test.tmp");
202        let txt_file = context_path.join("test.txt");
203
204        fs::File::create(&log_file).unwrap();
205        fs::File::create(&tmp_file).unwrap();
206        fs::File::create(&txt_file).unwrap();
207
208        assert!(context_builder.is_ignored(&log_file));
209        assert!(context_builder.is_ignored(&tmp_file));
210        assert!(!context_builder.is_ignored(&txt_file));
211    }
212
213    #[test_log::test(tokio::test)]
214    async fn test_adds_git_even_if_in_ignore() {
215        let dir = tempdir().unwrap();
216        let context_path = dir.path().to_path_buf();
217
218        // Create .gitignore file
219        let mut gitignore_file = fs::File::create(context_path.join(".gitignore")).unwrap();
220        writeln!(gitignore_file, ".git").unwrap();
221
222        let context_builder = ContextBuilder::from_path(&context_path).unwrap();
223
224        assert!(!context_builder.is_ignored(".git"));
225    }
226
227    #[test_log::test(tokio::test)]
228    async fn test_works_without_gitignore() {
229        let dir = tempdir().unwrap();
230        let context_path = dir.path().to_path_buf();
231
232        // Create .gitignore file
233
234        let context_builder = ContextBuilder::from_path(&context_path).unwrap();
235
236        assert!(!context_builder.is_ignored(".git"));
237        assert!(!context_builder.is_ignored("Dockerfile"));
238
239        fs::File::create(context_path.join("Dockerfile")).unwrap();
240
241        assert!(!context_builder.is_ignored("Dockerfile"));
242    }
243}