Skip to content

Commit c26320f

Browse files
committed
feat(cli):Add install-to-filesystem to copy file
Add cli `bootupctl backend install-to-filesystem` command, making file copying more robust and user-friendly. Key feature: - Flexible source paths: The `--file-path` now correctly handles an optional leading slash (`/`), always interpreting the path as relative to `src_root`. - Recursive destination dirs: Automatically creates the full parent directory structure in the destination (like `mkdir -p`), preventing errors from missing intermediate directories. This is idempotent. - Reliable file overwriting: Existing destination files are overwritten. - Improved ESP path logic: More robustly determines destination paths relative to the mounted ESP root when `--file-path` targets ESP content. Example: bootupctl backend install-to-filesystem \ --src-root /boot \ --file-path firmware/efi/somefile.bin \ /mnt/target_destination
1 parent 383903f commit c26320f

File tree

3 files changed

+291
-1
lines changed

3 files changed

+291
-1
lines changed

src/cli/bootupctl.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,8 @@ pub enum CtlBackend {
7373
Generate(super::bootupd::GenerateOpts),
7474
#[clap(name = "install", hide = true)]
7575
Install(super::bootupd::InstallOpts),
76+
#[clap(name = "install-to-filesystem", hide = true)]
77+
InstallToFilesystem(super::bootupd::InstallToFilesystemOpts),
7678
}
7779

7880
#[derive(Debug, Parser)]
@@ -102,6 +104,9 @@ impl CtlCommand {
102104
CtlVerb::Backend(CtlBackend::Install(opts)) => {
103105
super::bootupd::DCommand::run_install(opts)
104106
}
107+
CtlVerb::Backend(CtlBackend::InstallToFilesystem(opts)) => {
108+
super::bootupd::DCommand::run_install_to_filesystem(opts)
109+
}
105110
CtlVerb::MigrateStaticGrubConfig => Self::run_migrate_static_grub_config(),
106111
}
107112
}

src/cli/bootupd.rs

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,11 @@ pub enum DVerb {
3535
GenerateUpdateMetadata(GenerateOpts),
3636
#[clap(name = "install", about = "Install components")]
3737
Install(InstallOpts),
38+
#[clap(
39+
name = "install-to-filesystem",
40+
about = "Copy files between directories with ESP handling"
41+
)]
42+
InstallToFilesystem(InstallToFilesystemOpts),
3843
}
3944

4045
#[derive(Debug, Parser)]
@@ -82,16 +87,29 @@ pub struct GenerateOpts {
8287
sysroot: Option<String>,
8388
}
8489

90+
#[derive(Debug, Parser)]
91+
pub struct InstallToFilesystemOpts {
92+
#[clap(long, default_value_t = String::from("/") , help = "Source root directory")]
93+
src_root: String,
94+
95+
#[clap(long, help = "Destination root directory")]
96+
dest_root: String,
97+
98+
#[clap(value_parser, help = "file relative path")]
99+
file_path: String,
100+
}
101+
85102
impl DCommand {
86103
/// Run CLI application.
87104
pub fn run(self) -> Result<()> {
88105
match self.cmd {
89106
DVerb::Install(opts) => Self::run_install(opts),
90107
DVerb::GenerateUpdateMetadata(opts) => Self::run_generate_meta(opts),
108+
DVerb::InstallToFilesystem(opts) => Self::run_install_to_filesystem(opts),
91109
}
92110
}
93111

94-
/// Runner for `generate-install-metadata` verb.
112+
/// Runner for `generate-install-metadata` verb.``
95113
pub(crate) fn run_generate_meta(opts: GenerateOpts) -> Result<()> {
96114
let sysroot = opts.sysroot.as_deref().unwrap_or("/");
97115
if sysroot != "/" {
@@ -122,4 +140,9 @@ impl DCommand {
122140
.context("boot data installation failed")?;
123141
Ok(())
124142
}
143+
144+
pub(crate) fn run_install_to_filesystem(opts: InstallToFilesystemOpts) -> Result<()> {
145+
crate::filesystem::copy_files(&opts.src_root, &opts.dest_root, &opts.file_path)?;
146+
Ok(())
147+
}
125148
}

src/filesystem.rs

Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,14 @@ use std::process::Command;
55
use anyhow::Result;
66
use bootc_utils::CommandRunExt;
77
use fn_error_context::context;
8+
use openat_ext::OpenatDirExt;
89
use rustix::fd::BorrowedFd;
910
use serde::Deserialize;
1011

12+
use crate::blockdev;
13+
use crate::efi::Efi;
14+
use std::path::Path;
15+
1116
#[derive(Deserialize, Debug)]
1217
#[serde(rename_all = "kebab-case")]
1318
#[allow(dead_code)]
@@ -38,3 +43,260 @@ pub(crate) fn inspect_filesystem(root: &openat::Dir, path: &str) -> Result<Files
3843
.next()
3944
.ok_or_else(|| anyhow::anyhow!("findmnt returned no data"))
4045
}
46+
47+
#[context("Copying {file_path} from {src_root} to {dest_root}")]
48+
pub(crate) fn copy_files(src_root: &str, dest_root: &str, file_path: &str) -> Result<()> {
49+
let src_dir = openat::Dir::open(src_root)?;
50+
let file_path = file_path.strip_prefix("/").unwrap_or(file_path);
51+
let dest_dir = if file_path.starts_with("boot/efi") {
52+
let efi = Efi::default();
53+
match blockdev::get_single_device("/") {
54+
Ok(device) => {
55+
let esp_device = blockdev::get_esp_partition(&device)?;
56+
let esp_path = efi.ensure_mounted_esp(
57+
Path::new(dest_root),
58+
Path::new(&esp_device.unwrap_or_default()),
59+
)?;
60+
openat::Dir::open(&esp_path)?
61+
}
62+
Err(e) => anyhow::bail!("Unable to find device: {}", e),
63+
}
64+
} else {
65+
openat::Dir::open(dest_root)?
66+
};
67+
68+
let src_meta = src_dir.metadata(file_path)?;
69+
match src_meta.simple_type() {
70+
openat::SimpleType::File => {
71+
let parent = Path::new(file_path).parent().unwrap_or(Path::new("."));
72+
if !parent.as_os_str().is_empty() {
73+
dest_dir.ensure_dir_all(parent, 0o755)?;
74+
}
75+
src_dir.copy_file_at(file_path, &dest_dir, file_path)?;
76+
log::info!("Copied file: {} to destination", file_path);
77+
}
78+
openat::SimpleType::Dir => {
79+
anyhow::bail!("Unsupported copying of Directory {}", file_path)
80+
}
81+
openat::SimpleType::Symlink => {
82+
anyhow::bail!("Unsupported symbolic link {}", file_path)
83+
}
84+
openat::SimpleType::Other => {
85+
anyhow::bail!("Unsupported non-file/directory {}", file_path)
86+
}
87+
}
88+
89+
Ok(())
90+
}
91+
92+
// ... existing code in filesystem.rs ...
93+
94+
#[cfg(test)]
95+
mod test {
96+
use super::*;
97+
use anyhow::Result;
98+
use openat_ext::OpenatDirExt;
99+
use std::fs as std_fs;
100+
use std::io::Write;
101+
use tempfile::tempdir;
102+
103+
#[test]
104+
fn test_copy_single_file_basic() -> Result<()> {
105+
let tmp = tempdir()?;
106+
let tmp_root_dir = openat::Dir::open(tmp.path())?;
107+
108+
let src_root_name = "src_root";
109+
let dest_root_name = "dest_root";
110+
111+
tmp_root_dir.create_dir(src_root_name, 0o755)?;
112+
tmp_root_dir.create_dir(dest_root_name, 0o755)?;
113+
114+
let src_dir = tmp_root_dir.sub_dir(src_root_name)?;
115+
116+
let file_to_copy_rel = "file.txt";
117+
let content = "This is a test file.";
118+
119+
// Create source file using
120+
src_dir.write_file_contents(file_to_copy_rel, 0o644, content.as_bytes())?;
121+
122+
let src_root_abs_path_str = tmp.path().join(src_root_name).to_str().unwrap().to_string();
123+
let dest_root_abs_path_str = tmp
124+
.path()
125+
.join(dest_root_name)
126+
.to_str()
127+
.unwrap()
128+
.to_string();
129+
130+
copy_files(
131+
&src_root_abs_path_str,
132+
&dest_root_abs_path_str,
133+
file_to_copy_rel,
134+
)?;
135+
136+
let dest_file_abs_path = tmp.path().join(dest_root_name).join(file_to_copy_rel);
137+
assert!(dest_file_abs_path.exists(), "Destination file should exist");
138+
assert_eq!(
139+
std_fs::read_to_string(&dest_file_abs_path)?,
140+
content,
141+
"File content should match"
142+
);
143+
144+
Ok(())
145+
}
146+
147+
#[test]
148+
fn test_copy_file_in_subdirectory() -> Result<()> {
149+
let tmp = tempdir()?;
150+
let tmp_root_dir = openat::Dir::open(tmp.path())?;
151+
152+
let src_root_name = "src";
153+
let dest_root_name = "dest";
154+
155+
tmp_root_dir.create_dir(src_root_name, 0o755)?;
156+
tmp_root_dir.create_dir(dest_root_name, 0o755)?;
157+
158+
let src_dir_oat = tmp_root_dir.sub_dir(src_root_name)?;
159+
160+
let file_to_copy_rel = "subdir/another_file.txt";
161+
let content = "Content in a subdirectory.";
162+
163+
// Create subdirectory and file in source
164+
src_dir_oat.ensure_dir_all("subdir", 0o755)?;
165+
let mut f = src_dir_oat.write_file("subdir/another_file.txt", 0o644)?;
166+
f.write_all(content.as_bytes())?;
167+
f.flush()?;
168+
169+
let src_root_abs_path_str = tmp.path().join(src_root_name).to_str().unwrap().to_string();
170+
let dest_root_abs_path_str = tmp
171+
.path()
172+
.join(dest_root_name)
173+
.to_str()
174+
.unwrap()
175+
.to_string();
176+
177+
copy_files(
178+
&src_root_abs_path_str,
179+
&dest_root_abs_path_str,
180+
file_to_copy_rel,
181+
)?;
182+
183+
let dest_file_abs_path = tmp.path().join(dest_root_name).join(file_to_copy_rel);
184+
assert!(
185+
dest_file_abs_path.exists(),
186+
"Destination file in subdirectory should exist"
187+
);
188+
assert_eq!(
189+
std_fs::read_to_string(&dest_file_abs_path)?,
190+
content,
191+
"File content should match"
192+
);
193+
assert!(
194+
dest_file_abs_path.parent().unwrap().is_dir(),
195+
"Destination subdirectory should be a directory"
196+
);
197+
198+
Ok(())
199+
}
200+
201+
#[test]
202+
fn test_copy_file_with_leading_slash_in_filepath_arg() -> Result<()> {
203+
let tmp = tempdir()?;
204+
let tmp_root_dir = openat::Dir::open(tmp.path())?;
205+
206+
let src_root_name = "src";
207+
let dest_root_name = "dest";
208+
209+
tmp_root_dir.create_dir(src_root_name, 0o755)?;
210+
tmp_root_dir.create_dir(dest_root_name, 0o755)?;
211+
212+
let src_dir_oat = tmp_root_dir.sub_dir(src_root_name)?;
213+
214+
let file_rel_actual = "root_file.txt";
215+
let file_arg_with_slash = "/root_file.txt";
216+
let content = "Leading slash test.";
217+
218+
src_dir_oat.write_file_contents(file_rel_actual, 0o644, content.as_bytes())?;
219+
220+
let src_root_abs_path_str = tmp.path().join(src_root_name).to_str().unwrap().to_string();
221+
let dest_root_abs_path_str = tmp
222+
.path()
223+
.join(dest_root_name)
224+
.to_str()
225+
.unwrap()
226+
.to_string();
227+
228+
copy_files(
229+
&src_root_abs_path_str,
230+
&dest_root_abs_path_str,
231+
file_arg_with_slash,
232+
)?;
233+
234+
// The destination path should be based on the path *after* stripping the leading slash
235+
let dest_file_abs_path = tmp.path().join(dest_root_name).join(file_rel_actual);
236+
assert!(
237+
dest_file_abs_path.exists(),
238+
"Destination file should exist despite leading slash in arg"
239+
);
240+
assert_eq!(
241+
std_fs::read_to_string(&dest_file_abs_path)?,
242+
content,
243+
"File content should match"
244+
);
245+
246+
Ok(())
247+
}
248+
249+
#[test]
250+
fn test_copy_fails_for_directory() -> Result<()> {
251+
let tmp = tempdir()?;
252+
let tmp_root_dir = openat::Dir::open(tmp.path())?;
253+
254+
let src_root_name = "src";
255+
let dest_root_name = "dest";
256+
257+
tmp_root_dir.create_dir(src_root_name, 0o755)?;
258+
tmp_root_dir.create_dir(dest_root_name, 0o755)?;
259+
260+
let src_dir_oat = tmp_root_dir.sub_dir(src_root_name)?;
261+
262+
let dir_to_copy_rel = "a_directory";
263+
src_dir_oat.create_dir(dir_to_copy_rel, 0o755)?; // Create the directory in the source
264+
265+
let src_root_abs_path_str = tmp.path().join(src_root_name).to_str().unwrap().to_string();
266+
let dest_root_abs_path_str = tmp
267+
.path()
268+
.join(dest_root_name)
269+
.to_str()
270+
.unwrap()
271+
.to_string();
272+
273+
let result = copy_files(
274+
&src_root_abs_path_str,
275+
&dest_root_abs_path_str,
276+
dir_to_copy_rel,
277+
);
278+
279+
assert!(result.is_err(), "Copying a directory should fail.");
280+
if let Err(e) = result {
281+
let mut found_unsupported_message = false;
282+
for cause in e.chain() {
283+
// Iterate through the error chain
284+
if cause
285+
.to_string()
286+
.contains("Unsupported copying of Directory")
287+
{
288+
found_unsupported_message = true;
289+
break;
290+
}
291+
}
292+
assert!(
293+
found_unsupported_message,
294+
"The error chain should contain 'Unsupported copying of Directory'. Full error: {:#?}",
295+
e
296+
);
297+
} else {
298+
panic!("Expected an error when attempting to copy a directory, but got Ok.");
299+
}
300+
Ok(())
301+
}
302+
}

0 commit comments

Comments
 (0)