Skip to main content

ostree_ext/container/
skopeo.rs

1//! Fork skopeo as a subprocess
2
3use super::ImageReference;
4use anyhow::{Context, Result};
5use cap_std_ext::RootDir;
6use cap_std_ext::cap_std;
7use cap_std_ext::cmdext::{CapStdExtCommandExt, CmdFds};
8use containers_image_proxy::oci_spec::image as oci_image;
9use fn_error_context::context;
10use io_lifetimes::OwnedFd;
11use serde::Deserialize;
12use std::io::Read;
13use std::path::Path;
14use std::process::Stdio;
15use std::str::FromStr;
16use tokio::process::Command;
17
18// See `man containers-policy.json` and
19// https://github.com/containers/image/blob/main/signature/policy_types.go
20// Ideally we add something like `skopeo pull --disallow-insecure-accept-anything`
21// but for now we parse the policy.
22const INSECURE_ACCEPT_ANYTHING: &str = "insecureAcceptAnything";
23
24/// The env var that overrides the policy path, matching the upstream Go
25/// containers/image library behavior.
26const POLICY_ENV_VAR: &str = "CONTAINERS_POLICY_JSON";
27
28/// Well-known system paths for `containers-policy.json`, checked in order.
29const SYSTEM_POLICY_PATHS: &[&str] = &[
30    "etc/containers/policy.json",
31    "usr/share/containers/policy.json",
32];
33
34/// Suffix appended under `$XDG_CONFIG_HOME` (or `$HOME/.config`).
35const USER_POLICY_SUFFIX: &str = "containers/policy.json";
36
37/// Resolve the containers policy path using the same load order as the
38/// upstream Go containers/image library, with all lookups relative to `root`:
39///
40/// 1. `CONTAINERS_POLICY_JSON` env var (trusted, no existence check)
41/// 2. `$XDG_CONFIG_HOME/containers/policy.json` (or `$HOME/.config/…`)
42/// 3. `/etc/containers/policy.json`
43/// 4. `/usr/share/containers/policy.json`
44///
45/// For candidates 2–4 we only return a path when the file exists on disk.
46///
47/// Absolute paths (from env vars) have their leading `/` stripped so they
48/// resolve under `root`. Passing `root` opened on `/` gives normal behaviour;
49/// tests can pass a cap-std `Dir` backed by a temporary directory.
50///
51/// We use `RootDir` to handle absolute symlinks
52fn resolve_policy_path(
53    root: &RootDir,
54    env_override: Option<&Path>,
55    xdg_config_home: Option<&Path>,
56    home: Option<&Path>,
57) -> Result<std::fs::File> {
58    // Helper: strip a leading `/` so the path is relative to root.
59    fn strip_abs(p: &Path) -> &Path {
60        p.strip_prefix("/").unwrap_or(p)
61    }
62
63    // 1. Env var override – trust unconditionally (no existence check).
64    if let Some(raw) = env_override.filter(|v| !v.as_os_str().is_empty()) {
65        let relative = strip_abs(raw);
66        tracing::debug!("Using policy path from {POLICY_ENV_VAR}: {}", raw.display());
67        return root.open(relative).with_context(|| {
68            format!(
69                "Opening policy file from {POLICY_ENV_VAR}={}",
70                raw.display()
71            )
72        });
73    }
74
75    // 2. Per-user config dir.
76    let user_candidate = if let Some(xdg) = xdg_config_home {
77        Some(strip_abs(xdg).join(USER_POLICY_SUFFIX))
78    } else {
79        home.map(|h| strip_abs(h).join(".config").join(USER_POLICY_SUFFIX))
80    };
81    if let Some(p) = &user_candidate {
82        if let Ok(f) = root.open(p) {
83            tracing::debug!("Using user policy path: {}", p.display());
84            return Ok(f);
85        }
86    }
87
88    // 3–4. System paths.
89    for candidate in SYSTEM_POLICY_PATHS {
90        match root.open(candidate) {
91            Ok(f) => {
92                tracing::debug!("Using system policy path: {candidate}");
93                return Ok(f);
94            }
95            Err(e) => {
96                tracing::debug!("Opening {candidate}: {e:?}");
97                continue;
98            }
99        }
100    }
101
102    anyhow::bail!(
103        "No containers policy.json found; \
104         checked ${POLICY_ENV_VAR}, user config dir, and system paths"
105    )
106}
107
108#[derive(Deserialize)]
109struct PolicyEntry {
110    #[serde(rename = "type")]
111    ty: String,
112}
113#[derive(Deserialize)]
114struct ContainerPolicy {
115    default: Option<Vec<PolicyEntry>>,
116}
117
118impl ContainerPolicy {
119    fn is_default_insecure(&self) -> bool {
120        if let Some(default) = self.default.as_deref() {
121            match default.split_first() {
122                Some((v, &[])) => v.ty == INSECURE_ACCEPT_ANYTHING,
123                _ => false,
124            }
125        } else {
126            false
127        }
128    }
129}
130
131pub(crate) fn container_policy_is_default_insecure(root: &cap_std::fs::Dir) -> Result<bool> {
132    let root = &RootDir::new(root, ".").context("Opening RootDir")?;
133    let f = resolve_policy_path(
134        root,
135        std::env::var_os(POLICY_ENV_VAR).as_deref().map(Path::new),
136        std::env::var_os("XDG_CONFIG_HOME")
137            .as_deref()
138            .map(Path::new),
139        std::env::var_os("HOME").as_deref().map(Path::new),
140    )
141    .context("Resolving containers policy path")?;
142    let r = std::io::BufReader::new(f);
143    let policy: ContainerPolicy = serde_json::from_reader(r)?;
144    Ok(policy.is_default_insecure())
145}
146
147/// Create a Command builder for skopeo.
148pub(crate) fn new_cmd() -> std::process::Command {
149    let mut cmd = std::process::Command::new(bootc_utils::skopeo_bin());
150    cmd.stdin(Stdio::null());
151    cmd
152}
153
154/// Spawn the child process
155pub(crate) fn spawn(mut cmd: Command) -> Result<tokio::process::Child> {
156    let cmd = cmd.stdin(Stdio::null()).stderr(Stdio::piped());
157    cmd.spawn().context("Failed to exec skopeo")
158}
159
160/// Use skopeo to copy a container image.
161#[context("Skopeo copy")]
162pub async fn copy(
163    src: &ImageReference,
164    dest: &ImageReference,
165    authfile: Option<&Path>,
166    add_fd: Option<(std::sync::Arc<OwnedFd>, i32)>,
167    progress: bool,
168) -> Result<oci_image::Digest> {
169    let digestfile = tempfile::NamedTempFile::new()?;
170    let mut cmd = new_cmd();
171    cmd.arg("copy");
172    if !progress {
173        cmd.stdout(std::process::Stdio::null());
174    }
175    cmd.arg("--digestfile");
176    cmd.arg(digestfile.path());
177    if let Some((add_fd, n)) = add_fd {
178        let mut fds = CmdFds::new();
179        fds.take_fd_n(add_fd, n);
180        cmd.take_fds(fds);
181    }
182    if let Some(authfile) = authfile {
183        cmd.arg("--authfile");
184        cmd.arg(authfile);
185    }
186    cmd.args(&[src.to_string(), dest.to_string()]);
187    let mut cmd = tokio::process::Command::from(cmd);
188    cmd.kill_on_drop(true);
189    let proc = super::skopeo::spawn(cmd)?;
190    let output = proc.wait_with_output().await?;
191    if !output.status.success() {
192        let stderr = String::from_utf8_lossy(&output.stderr);
193        return Err(anyhow::anyhow!("skopeo failed: {}\n", stderr));
194    }
195    let mut digestfile = digestfile.into_file();
196    let mut r = String::new();
197    digestfile.read_to_string(&mut r)?;
198    Ok(oci_image::Digest::from_str(r.trim())?)
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204    use cap_std_ext::cap_tempfile;
205
206    // Default value as of the Fedora 34 containers-common-1-21.fc34.noarch package.
207    const DEFAULT_POLICY: &str = indoc::indoc! {r#"
208    {
209        "default": [
210            {
211                "type": "insecureAcceptAnything"
212            }
213        ],
214        "transports":
215            {
216                "docker-daemon":
217                    {
218                        "": [{"type":"insecureAcceptAnything"}]
219                    }
220            }
221    }
222    "#};
223
224    // Stripped down copy from the manual.
225    const REASONABLY_LOCKED_DOWN: &str = indoc::indoc! { r#"
226    {
227        "default": [{"type": "reject"}],
228        "transports": {
229            "dir": {
230                "": [{"type": "insecureAcceptAnything"}]
231            },
232            "atomic": {
233                "hostname:5000/myns/official": [
234                    {
235                        "type": "signedBy",
236                        "keyType": "GPGKeys",
237                        "keyPath": "/path/to/official-pubkey.gpg"
238                    }
239                ]
240            }
241        }
242    }
243    "#};
244
245    #[test]
246    fn policy_is_insecure() {
247        let p: ContainerPolicy = serde_json::from_str(DEFAULT_POLICY).unwrap();
248        assert!(p.is_default_insecure());
249        for &v in &["{}", REASONABLY_LOCKED_DOWN] {
250            let p: ContainerPolicy = serde_json::from_str(v).unwrap();
251            assert!(!p.is_default_insecure());
252        }
253    }
254
255    /// Create `<dir>/<path>` with empty JSON content, creating parent dirs.
256    /// Returns the (dev, ino) of the created file for identity checks.
257    fn touch(dir: &cap_std::fs::Dir, path: &str) -> (u64, u64) {
258        use cap_std::fs::MetadataExt;
259        if let Some(parent) = Path::new(path).parent() {
260            dir.create_dir_all(parent).unwrap();
261        }
262        dir.write(path, b"{}").unwrap();
263        let m = dir.metadata(path).unwrap();
264        (m.dev(), m.ino())
265    }
266
267    /// Return (dev, ino) for an open file.
268    fn file_id(f: &std::fs::File) -> (u64, u64) {
269        use std::os::unix::fs::MetadataExt;
270        let m = f.metadata().unwrap();
271        (m.dev(), m.ino())
272    }
273
274    fn to_root_dir(dir: &cap_std::fs::Dir) -> RootDir {
275        RootDir::new(dir, ".").unwrap()
276    }
277
278    #[test]
279    fn resolve_policy_path_cases() -> Result<()> {
280        let td = cap_tempfile::TempDir::new(cap_std::ambient_authority())?;
281        let root = to_root_dir(&td);
282
283        let etc_id = touch(&td, "etc/containers/policy.json");
284        let _usr_id = touch(&td, "usr/share/containers/policy.json");
285
286        // Env var override wins (trusted — errors if file missing)
287        let custom = Path::new("/custom/policy.json");
288        assert!(resolve_policy_path(&root, Some(custom), None, None).is_err());
289        let custom_id = touch(&td, "custom/policy.json");
290        let f = resolve_policy_path(&root, Some(custom), None, None)?;
291        assert_eq!(
292            file_id(&f),
293            custom_id,
294            "env var should open the custom file"
295        );
296
297        // Empty env var is ignored, falls through to /etc
298        let f = resolve_policy_path(&root, Some(Path::new("")), None, None)?;
299        assert_eq!(
300            file_id(&f),
301            etc_id,
302            "empty env var should fall through to /etc"
303        );
304
305        // XDG_CONFIG_HOME wins when file exists
306        let xdg_id = touch(&td, "xdg/containers/policy.json");
307        let f = resolve_policy_path(&root, None, Some(Path::new("/xdg")), None)?;
308        assert_eq!(file_id(&f), xdg_id, "XDG_CONFIG_HOME should win");
309
310        // XDG_CONFIG_HOME skipped when file missing, falls through to /etc
311        let f = resolve_policy_path(&root, None, Some(Path::new("/xdg-empty")), None)?;
312        assert_eq!(file_id(&f), etc_id, "missing XDG dir should fall through");
313
314        // HOME/.config fallback when XDG unset
315        let home_id = touch(&td, "home/.config/containers/policy.json");
316        let f = resolve_policy_path(&root, None, None, Some(Path::new("/home")))?;
317        assert_eq!(file_id(&f), home_id, "HOME fallback should work");
318
319        // /etc preferred over /usr/share
320        let f = resolve_policy_path(&root, None, None, None)?;
321        assert_eq!(
322            file_id(&f),
323            etc_id,
324            "/etc should be preferred over /usr/share"
325        );
326
327        // Falls through to /usr/share when /etc missing
328        let td2 = cap_tempfile::TempDir::new(cap_std::ambient_authority())?;
329        let root2 = to_root_dir(&td2);
330        let usr2_id = touch(&td2, "usr/share/containers/policy.json");
331        let f = resolve_policy_path(&root2, None, None, None)?;
332        assert_eq!(file_id(&f), usr2_id, "should fall through to /usr/share");
333
334        // Nothing found returns error
335        let td3 = cap_tempfile::TempDir::new(cap_std::ambient_authority())?;
336        let root3 = to_root_dir(&td3);
337        assert!(resolve_policy_path(&root3, None, None, None).is_err());
338
339        Ok(())
340    }
341
342    /// Create an absolute symlink inside a cap-std Dir. We need to go via
343    /// procfs because cap-std (by design) refuses to create symlinks with
344    /// absolute targets.
345    fn symlink_absolute(dir: &cap_std::fs::Dir, target: &str, link: &str) {
346        use std::os::fd::AsRawFd;
347        let real_dir = format!("/proc/self/fd/{}", dir.as_raw_fd());
348        let link_path = std::path::PathBuf::from(real_dir).join(link);
349        std::os::unix::fs::symlink(target, &link_path).unwrap();
350    }
351
352    #[test]
353    fn resolve_policy_path_absolute_symlink() -> Result<()> {
354        let td = cap_tempfile::TempDir::new(cap_std::ambient_authority())?;
355        let root = to_root_dir(&td);
356
357        // Create the real file at a different location
358        let real_id = touch(&td, "real/policy.json");
359
360        // RootDir uses RESOLVE_IN_ROOT, so absolute symlinks are followed
361        // within the root automatically.
362        td.create_dir_all("etc/containers")?;
363        symlink_absolute(&td, "/real/policy.json", "etc/containers/policy.json");
364
365        let f = resolve_policy_path(&root, None, None, None)?;
366        assert_eq!(
367            file_id(&f),
368            real_id,
369            "absolute symlink should be resolved relative to root"
370        );
371
372        // Symlink whose target doesn't exist should fall through
373        let td2 = cap_tempfile::TempDir::new(cap_std::ambient_authority())?;
374        let root2 = to_root_dir(&td2);
375        td2.create_dir_all("etc/containers")?;
376        symlink_absolute(
377            &td2,
378            "/nonexistent/policy.json",
379            "etc/containers/policy.json",
380        );
381        let fallback_id = touch(&td2, "usr/share/containers/policy.json");
382        let f = resolve_policy_path(&root2, None, None, None)?;
383        assert_eq!(
384            file_id(&f),
385            fallback_id,
386            "broken symlink should fall through to next candidate"
387        );
388
389        Ok(())
390    }
391}