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