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