diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 4eb1f5bf74..1e97cc60e5 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -28,6 +28,7 @@ dependencies = [ "clap", "console", "curl", + "fluent-uri", "home", "indicatif", "inquire", @@ -52,6 +53,7 @@ dependencies = [ "cidr", "curl", "env_logger", + "fluent-uri", "fs_extra", "futures-util", "home", @@ -722,6 +724,12 @@ dependencies = [ "piper", ] +[[package]] +name = "borrow-or-share" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eeab4423108c5d7c744f4d234de88d18d636100093ae04caf4825134b9c3a32" + [[package]] name = "brotli" version = "7.0.0" @@ -1391,6 +1399,17 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "fluent-uri" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1918b65d96df47d3591bed19c5cca17e3fa5d0707318e4b5ef2eae01764df7e5" +dependencies = [ + "borrow-or-share", + "ref-cast", + "serde", +] + [[package]] name = "fnv" version = "1.0.7" @@ -3050,6 +3069,26 @@ dependencies = [ "thiserror", ] +[[package]] +name = "ref-cast" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a0ae411dbe946a674d89546582cea4ba2bb8defac896622d6496f14c23ba5cf" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1165225c21bff1f3bbce98f5a1f889949bc902d3575308cc7b0de30b4f6d27c7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.87", +] + [[package]] name = "regex" version = "1.11.1" @@ -4190,6 +4229,7 @@ dependencies = [ "form_urlencoded", "idna 0.5.0", "percent-encoding", + "serde", ] [[package]] @@ -4236,6 +4276,7 @@ dependencies = [ "quote", "regex", "syn 2.0.87", + "url", "uuid", ] diff --git a/rust/agama-cli/Cargo.toml b/rust/agama-cli/Cargo.toml index 315d9266fe..c04964ed76 100644 --- a/rust/agama-cli/Cargo.toml +++ b/rust/agama-cli/Cargo.toml @@ -25,6 +25,7 @@ chrono = "0.4.38" regex = "1.11.1" home = "0.5.11" serde = { version = "1.0.219", features = ["derive"] } +fluent-uri = "0.3.2" [[bin]] name = "agama" diff --git a/rust/agama-cli/src/commands.rs b/rust/agama-cli/src/commands.rs index 16d464051e..81b1e744a1 100644 --- a/rust/agama-cli/src/commands.rs +++ b/rust/agama-cli/src/commands.rs @@ -99,13 +99,14 @@ pub enum Commands { /// Download file from given URL /// - /// The purpose of this command is to download files using AutoYaST supported schemas (e.g. device:// or relurl://). + /// The purpose of this command is to download files using AutoYaST supported schemas (e.g. device://). /// It can be used to download additional scripts, configuration files and so on. /// You can use it for downloading Agama autoinstallation profiles. However, unless you need additional processing, /// the "agama profile import" is recommended. /// If you want to convert an AutoYaST profile, use "agama profile autoyast". Download { - /// URL pointing to file for download + /// URL reference pointing to file for download. If a relative URL is + /// provided, it will be resolved against the current working directory. url: String, /// File name destination: PathBuf, diff --git a/rust/agama-cli/src/config.rs b/rust/agama-cli/src/config.rs index 6a0cf65bc8..b2047ceb14 100644 --- a/rust/agama-cli/src/config.rs +++ b/rust/agama-cli/src/config.rs @@ -26,7 +26,8 @@ use std::{ use crate::show_progress; use agama_lib::{ - base_http_client::BaseHTTPClient, install_settings::InstallSettings, Store as SettingsStore, + base_http_client::BaseHTTPClient, context::InstallationContext, + install_settings::InstallSettings, Store as SettingsStore, }; use anyhow::anyhow; use clap::Subcommand; @@ -75,7 +76,7 @@ pub async fn run(http_client: BaseHTTPClient, subcommand: ConfigCommands) -> any let mut stdin = io::stdin(); let mut contents = String::new(); stdin.read_to_string(&mut contents)?; - let result: InstallSettings = serde_json::from_str(&contents)?; + let result = InstallSettings::from_json(&contents, &InstallationContext::from_env()?)?; tokio::spawn(async move { show_progress().await.unwrap(); }); @@ -112,7 +113,10 @@ fn edit(model: &InstallSettings, editor: &str) -> anyhow::Result() -> anyhow::Result> { Ok(ManagerClient::new(conn).await?) } -pub fn download_file(url: &str, path: &PathBuf) -> Result<(), ServiceError> { +pub fn download_file(url: &str, path: &PathBuf) -> anyhow::Result<()> { let mut file = fs::OpenOptions::new() .create(true) .write(true) - .mode(0o400) + .truncate(true) + .mode(0o600) .open(path) .context(format!("Cannot write the file '{}'", path.display()))?; - match Transfer::get(url, &mut file) { + let context = InstallationContext::from_env().unwrap(); + let uri = UriRef::parse(url).context("Invalid URL")?; + let absolute_url = if uri.has_scheme() { + uri.to_string() + } else { + uri.resolve_against(&context.source)?.to_string() + }; + + match Transfer::get(&absolute_url, &mut file) { Ok(()) => println!("File saved to {}", path.display()), Err(e) => eprintln!("Could not retrieve the file: {e}"), } diff --git a/rust/agama-cli/src/profile.rs b/rust/agama-cli/src/profile.rs index f0f010f5a0..7b3580c061 100644 --- a/rust/agama-cli/src/profile.rs +++ b/rust/agama-cli/src/profile.rs @@ -20,12 +20,17 @@ use crate::show_progress; use agama_lib::{ - base_http_client::BaseHTTPClient, install_settings::InstallSettings, - profile::ValidationOutcome, utils::FileFormat, utils::Transfer, Store as SettingsStore, + base_http_client::BaseHTTPClient, + context::InstallationContext, + install_settings::InstallSettings, + profile::ValidationOutcome, + utils::{FileFormat, Transfer}, + Store as SettingsStore, }; use anyhow::Context; use clap::Subcommand; use console::style; +use fluent_uri::Uri; use std::{ io, io::Read, @@ -228,11 +233,11 @@ async fn import(client: BaseHTTPClient, url_string: String) -> anyhow::Result<() } }); - let url = Url::parse(&url_string)?; - let path = url.path(); + let url = Uri::parse(url_string.as_str())?; + let path = url.path().to_string(); let profile_json = if path.ends_with(".xml") || path.ends_with(".erb") || path.ends_with('/') { // AutoYaST specific download and convert to JSON - let config_string = autoyast_client(&client, &url).await?; + let config_string = autoyast_client(&client, &url.to_owned()).await?; Some(config_string) } else { pre_process_profile(&client, &url_string).await? @@ -241,7 +246,10 @@ async fn import(client: BaseHTTPClient, url_string: String) -> anyhow::Result<() // None means the profile is a script and it has been executed if let Some(profile_json) = profile_json { validate(&client, CliInput::Full(profile_json.clone())).await?; - store_settings(client, &profile_json).await?; + let context = InstallationContext { + source: url.to_owned(), + }; + store_settings(client, &profile_json, &context).await?; } Ok(()) } @@ -280,9 +288,13 @@ async fn pre_process_profile( } } -async fn store_settings(client: BaseHTTPClient, profile_json: &str) -> anyhow::Result<()> { +async fn store_settings( + client: BaseHTTPClient, + profile_json: &str, + context: &InstallationContext, +) -> anyhow::Result<()> { let store = SettingsStore::new(client).await?; - let settings: InstallSettings = serde_json::from_str(profile_json)?; + let settings = InstallSettings::from_json(profile_json, context)?; store.store(&settings).await?; Ok(()) } @@ -291,7 +303,7 @@ async fn store_settings(client: BaseHTTPClient, profile_json: &str) -> anyhow::R /// Note that this client does not act on this *url*, it passes it as a parameter /// to our web backend. /// Return well-formed Agama JSON on success. -async fn autoyast_client(client: &BaseHTTPClient, url: &Url) -> anyhow::Result { +async fn autoyast_client(client: &BaseHTTPClient, url: &Uri) -> anyhow::Result { // FIXME: how to escape it? let api_url = format!("/profile/autoyast?url={}", url); let output: Box = client.post(&api_url, &()).await?; @@ -300,8 +312,8 @@ async fn autoyast_client(client: &BaseHTTPClient, url: &Url) -> anyhow::Result anyhow::Result<()> { - let url = Url::parse(&url_string)?; - let output = autoyast_client(&client, &url).await?; + let url = Uri::parse(url_string.as_str())?; + let output = autoyast_client(&client, &url.to_owned()).await?; println!("{}", output); Ok(()) } diff --git a/rust/agama-lib/Cargo.toml b/rust/agama-lib/Cargo.toml index 90efb95bf0..3f41a29bf0 100644 --- a/rust/agama-lib/Cargo.toml +++ b/rust/agama-lib/Cargo.toml @@ -20,8 +20,8 @@ tempfile = "3.13.0" thiserror = "1.0.64" tokio = { version = "1.40.0", features = ["macros", "rt-multi-thread"] } tokio-stream = "0.1.16" -url = "2.5.2" -utoipa = "5.2.0" +url = { version = "2.5.2", features = ["serde"] } +utoipa = { version = "5.2.0", features = ["url"] } zbus = { version = "5", default-features = false, features = ["tokio"] } # Needed to define curl error in profile errors curl = { version = "0.4.47", features = ["protocol-ftp"] } @@ -37,6 +37,7 @@ strum = { version = "0.27.1", features = ["derive"] } fs_extra = "1.3.0" serde_with = "3.12.0" regex = "1.11.1" +fluent-uri = { version = "0.3.2", features = ["serde"] } [dev-dependencies] httpmock = "0.7.0" diff --git a/rust/agama-lib/share/profile.schema.json b/rust/agama-lib/share/profile.schema.json index dad02a0fc4..b0376704c6 100644 --- a/rust/agama-lib/share/profile.schema.json +++ b/rust/agama-lib/share/profile.schema.json @@ -600,8 +600,8 @@ "type": "string" }, "url": { - "title": "Script URL", - "description": "URL to fetch the script from" + "title": "Script URL reference", + "description": "Absolute or relative URL to fetch the script from" } }, "required": ["name"], @@ -628,8 +628,8 @@ "type": "string" }, "url": { - "title": "Script URL", - "description": "URL to fetch the script from" + "title": "Script URL reference", + "description": "Absolute or relative URL to fetch the script from." } }, "required": ["name"], @@ -656,8 +656,8 @@ "type": "string" }, "url": { - "title": "Script URL", - "description": "URL to fetch the script from" + "title": "Script URL reference", + "description": "Absolute or relative URL to fetch the script from." }, "chroot": { "title": "Whether it should run in the installed system using a chroot environment", @@ -689,8 +689,8 @@ "type": "string" }, "url": { - "title": "Script URL", - "description": "URL to fetch the script from" + "title": "Script URL reference", + "description": "Absolute or relative URL to fetch the script from." } }, "required": ["name"], @@ -711,8 +711,8 @@ "type": "string" }, "url": { - "title": "File URL", - "description": "URL to fetch the file from" + "title": "File URL reference", + "description": "Absolute or relative URL to fetch the file from." }, "permissions": { "title": "File permissions", diff --git a/rust/agama-lib/src/context.rs b/rust/agama-lib/src/context.rs new file mode 100644 index 0000000000..7eeedb675d --- /dev/null +++ b/rust/agama-lib/src/context.rs @@ -0,0 +1,46 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use fluent_uri::Uri; + +#[derive(Debug, thiserror::Error)] +#[error("Could not determine the installation context")] +pub struct InstallationContextError(String); + +/// It contains context information for the store. +#[derive(Debug)] +pub struct InstallationContext { + /// Where the installation settings are from. + /// Used for resolving relative URL references. + pub source: Uri, +} + +impl InstallationContext { + /// Sets _source_ to the current directory. + pub fn from_env() -> Result { + let current_path = + std::env::current_dir().map_err(|e| InstallationContextError(e.to_string()))?; + let url = format!("file://{}", current_path.as_path().display()); + let url = Uri::parse(url.as_str()).map_err(|e| InstallationContextError(e.to_string()))?; + Ok(Self { + source: url.to_owned(), + }) + } +} diff --git a/rust/agama-lib/src/error.rs b/rust/agama-lib/src/error.rs index 788d8f5a65..a500ad2568 100644 --- a/rust/agama-lib/src/error.rs +++ b/rust/agama-lib/src/error.rs @@ -59,7 +59,7 @@ pub enum ServiceError { // FIXME reroute the error to a better place #[error("Profile error: {0}")] Profile(#[from] ProfileError), - #[error("Unsupported SSL Fingeprint algorithm '#{0}'.")] + #[error("Unsupported SSL Fingerprint algorithm '#{0}'.")] UnsupportedSSLFingerprintAlgorithm(String), } diff --git a/rust/agama-lib/src/file_source.rs b/rust/agama-lib/src/file_source.rs new file mode 100644 index 0000000000..05b3bb57b0 --- /dev/null +++ b/rust/agama-lib/src/file_source.rs @@ -0,0 +1,187 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use crate::utils::{Transfer, TransferError}; +use fluent_uri::{error::ResolveError, Uri, UriRef}; +use serde::{Deserialize, Serialize}; +use std::{fs::OpenOptions, io::Write, os::unix::fs::OpenOptionsExt, path::Path}; + +#[derive(Debug, thiserror::Error)] +pub enum FileSourceError { + #[error("Could not resolve the URL: {0}")] + ResolveUrlError(String, #[source] ResolveError), + #[error("Transfer error: {0}")] + TransferFailed(#[from] TransferError), + #[error("I/O error: {0}")] + IO(#[from] std::io::Error), +} + +#[derive(Clone, Debug, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(untagged)] +/// Text or URL Reference of a config file or a script +pub enum FileSource { + /// File content. + Text { content: String }, + /// URI or relative reference to get the script from. + Remote { + #[schema(value_type = String, examples("http://example.com/script.sh", "/file.txt"))] + url: UriRef, + }, +} + +impl FileSource { + /// Returns a new source using an absolute URL if it was using a relative one. + /// + /// If it was not using a relative URL, it just returns a clone. + /// + /// * `base`: base URL. + pub fn resolve_url(&self, base: &Uri) -> Result { + let resolved = match self { + Self::Text { content } => Self::Text { + content: content.clone(), + }, + Self::Remote { url } => { + let resolved_url = if url.has_scheme() { + url.clone() + } else { + let resolved = url + .resolve_against(base) + .map_err(|e| FileSourceError::ResolveUrlError(url.to_string(), e))?; + UriRef::from(resolved) + }; + Self::Remote { url: resolved_url } + } + }; + Ok(resolved) + } + + /// Writes the file to the given writer. + /// + /// * `file`: where to write the data. + pub fn write>(&self, path: P, mode: u32) -> Result<(), FileSourceError> { + let mut file = OpenOptions::new() + .mode(mode) + .write(true) + .create(true) + .open(path)?; + + match &self { + FileSource::Text { content } => file.write_all(content.as_bytes())?, + // Transfer::get will fail if the URL is relative. + FileSource::Remote { url } => Transfer::get(&url.to_string(), &mut file)?, + } + + file.flush()?; + Ok(()) + } +} + +/// Implements an API to work with a file source. +pub trait WithFileSource: Clone { + /// File source. + fn file_source(&self) -> &FileSource; + + /// Mutable file source. + fn file_source_mut(&mut self) -> &mut FileSource; + + /// Returns a clone using an absolute URL for the file source. + /// + /// * `base`: base URL. + fn resolve_url(&mut self, base: &Uri) -> Result<(), FileSourceError> { + let source = self.file_source_mut(); + *source = source.resolve_url(base)?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use std::{fs::File, io::Write}; + + use fluent_uri::{Uri, UriRef}; + + use super::FileSource; + + #[test] + fn test_write_content() { + let file = FileSource::Text { + content: "foobar".to_string(), + }; + + let tmpdir = tempfile::TempDir::with_prefix("agama-tests-").unwrap(); + let target = tmpdir.path().join("foobar.txt"); + file.write(&target, 0o400).unwrap(); + + let written = std::fs::read_to_string(&target).unwrap(); + assert_eq!(written.as_str(), "foobar"); + } + + #[test] + fn test_write_from_url() { + let tmpdir = tempfile::TempDir::with_prefix("agama-tests-").unwrap(); + let source = tmpdir.path().join("source.txt"); + let mut file = File::create(&source).unwrap(); + file.write_all(b"foobar").unwrap(); + + let url = format!("file://{}", source.display()); + let file = FileSource::Remote { + url: UriRef::parse(url).unwrap(), + }; + let target = tmpdir.path().join("foobar.txt"); + file.write(&target, 0o400).unwrap(); + + let written = std::fs::read_to_string(&target).unwrap(); + assert_eq!(written.as_str(), "foobar"); + } + + #[test] + fn test_resolve_url_relative() { + let file = FileSource::Remote { + url: UriRef::parse("file.txt").unwrap().to_owned(), + }; + + let base_url = Uri::parse("http://example.lan/sles").unwrap().to_owned(); + let resolved = file.resolve_url(&base_url).unwrap(); + let expected_url = "http://example.lan/file.txt"; + + assert!(matches!( + resolved, + FileSource::Remote { url } if url.as_str() == expected_url + )); + } + + #[test] + fn test_resolve_url_absolute() { + let file = FileSource::Remote { + url: UriRef::parse("http://example.lan/agama/file.txt") + .unwrap() + .to_owned(), + }; + + let base_url = Uri::parse("http://example.lan/sles").unwrap().to_owned(); + let resolved = file.resolve_url(&base_url).unwrap(); + let expected_url = "http://example.lan/agama/file.txt"; + + assert!(matches!( + resolved, + FileSource::Remote { url } if url.as_str() == expected_url + )); + } +} diff --git a/rust/agama-lib/src/files/error.rs b/rust/agama-lib/src/files/error.rs index c8f3b46300..91b6378017 100644 --- a/rust/agama-lib/src/files/error.rs +++ b/rust/agama-lib/src/files/error.rs @@ -21,7 +21,7 @@ use std::{io, num::ParseIntError}; use thiserror::Error; -use crate::utils::TransferError; +use crate::{file_source::FileSourceError, utils::TransferError}; #[derive(Error, Debug)] pub enum FileError { @@ -35,4 +35,6 @@ pub enum FileError { OwnerChangeError(String, String), #[error("Failed to create directories: command '{0}' stderr '{1}'")] MkdirError(String, String), + #[error(transparent)] + FileSourceError(#[from] FileSourceError), } diff --git a/rust/agama-lib/src/files/model.rs b/rust/agama-lib/src/files/model.rs index 17f1eb9623..5761ef399b 100644 --- a/rust/agama-lib/src/files/model.rs +++ b/rust/agama-lib/src/files/model.rs @@ -20,30 +20,16 @@ //! Implements a data model for Files configuration. -use serde::{Deserialize, Serialize}; -use std::fs::OpenOptions; -use std::io::Write; -use std::os::unix::fs::OpenOptionsExt; -use std::path::Path; -use std::process; - -use crate::utils::Transfer; - use super::error::FileError; - -#[derive(Clone, Debug, Serialize, Deserialize, utoipa::ToSchema)] -#[serde(untagged)] -pub enum FileSource { - /// File body directly written - Text { content: String }, - /// URL to get the file from. - Remote { url: String }, -} +use crate::file_source::{FileSource, WithFileSource}; +use serde::{Deserialize, Serialize}; +use std::{path::Path, process}; /// Represents individual settings for single file deployment #[derive(Clone, Debug, Serialize, Deserialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct UserFile { + /// File content or URL. #[serde(flatten)] pub source: FileSource, /// Permissions for file @@ -119,20 +105,7 @@ impl UserFile { )); } // cannot set owner here as user and group can exist only on target destination - let mut target = OpenOptions::new() - .mode(int_mode) - .write(true) - .create(true) - .open(path)?; - match &self.source { - FileSource::Remote { url } => { - Transfer::get(url, &mut target)?; - } - FileSource::Text { content } => { - target.write_all(content.as_bytes())?; - } - } - target.flush()?; + self.source.write(path, int_mode)?; let mut cmd2 = process::Command::new("chroot"); cmd2.args([ @@ -152,3 +125,15 @@ impl UserFile { Ok(()) } } + +impl WithFileSource for UserFile { + /// File source. + fn file_source(&self) -> &FileSource { + &self.source + } + + /// Mutable file source. + fn file_source_mut(&mut self) -> &mut FileSource { + &mut self.source + } +} diff --git a/rust/agama-lib/src/files/settings.rs b/rust/agama-lib/src/files/settings.rs index 212e10b4ba..2cc36ef377 100644 --- a/rust/agama-lib/src/files/settings.rs +++ b/rust/agama-lib/src/files/settings.rs @@ -18,9 +18,8 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use serde::{Deserialize, Serialize}; - use super::model::UserFile; +use serde::{Deserialize, Serialize}; #[derive(Debug, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] diff --git a/rust/agama-lib/src/files/store.rs b/rust/agama-lib/src/files/store.rs index ef951863ac..ef6cdd16d2 100644 --- a/rust/agama-lib/src/files/store.rs +++ b/rust/agama-lib/src/files/store.rs @@ -27,8 +27,10 @@ use super::{ use crate::base_http_client::BaseHTTPClient; #[derive(Debug, thiserror::Error)] -#[error("Error processing files settings: {0}")] -pub struct FilesStoreError(#[from] FilesHTTPClientError); +pub enum FilesStoreError { + #[error("Error processing files settings: {0}")] + FilesHTTPClient(#[from] FilesHTTPClientError), +} type FilesStoreResult = Result; diff --git a/rust/agama-lib/src/install_settings.rs b/rust/agama-lib/src/install_settings.rs index ea1124272f..a4cf324113 100644 --- a/rust/agama-lib/src/install_settings.rs +++ b/rust/agama-lib/src/install_settings.rs @@ -1,4 +1,4 @@ -// Copyright (c) [2024] SUSE LLC +// Copyright (c) [2024-2025] SUSE LLC // // All Rights Reserved. // @@ -22,6 +22,8 @@ //! //! This module implements the mechanisms to load and store the installation settings. use crate::bootloader::model::BootloaderSettings; +use crate::context::InstallationContext; +use crate::file_source::{FileSourceError, WithFileSource}; use crate::files::model::UserFile; use crate::hostname::model::HostnameSettings; use crate::security::settings::SecuritySettings; @@ -29,13 +31,22 @@ use crate::{ localization::LocalizationSettings, network::NetworkSettings, product::ProductSettings, scripts::ScriptsConfig, software::SoftwareSettings, users::UserSettings, }; +use fluent_uri::Uri; use serde::{Deserialize, Serialize}; use serde_json::value::RawValue; use std::default::Default; -use std::fs::File; -use std::io::BufReader; use std::path::Path; +#[derive(Debug, thiserror::Error)] +pub enum InstallSettingsError { + #[error("I/O error: {0}")] + InputOuputError(#[from] std::io::Error), + #[error("Could not parse the settings: {0}")] + ParseError(#[from] serde_json::Error), + #[error(transparent)] + FileSourceError(#[from] FileSourceError), +} + /// Installation settings /// /// This struct represents installation settings. It serves as an entry point and it is composed of @@ -80,10 +91,44 @@ pub struct InstallSettings { } impl InstallSettings { - pub fn from_file>(path: P) -> Result { - let file = File::open(path)?; - let reader = BufReader::new(file); - let data = serde_json::from_reader(reader)?; - Ok(data) + /// Returns install settings from a file. + pub fn from_file>( + path: P, + context: &InstallationContext, + ) -> Result { + let content = std::fs::read_to_string(path)?; + Ok(Self::from_json(&content, context)?) + } + + /// Reads install settings from a JSON string, + /// also resolving relative URLs in the contents. + /// + /// - `json`: JSON string. + /// - `context`: Store context. + pub fn from_json( + json: &str, + context: &InstallationContext, + ) -> Result { + let mut settings: InstallSettings = serde_json::from_str(json)?; + settings.resolve_urls(&context.source).unwrap(); + Ok(settings) + } + + /// Resolves URLs in the settings. + /// + // Ideally, the context could be ready when deserializing the settings so + // the URLs can be resolved. One possible solution would be to use + // [DeserializeSeed](https://docs.rs/serde/1.0.219/serde/de/trait.DeserializeSeed.html). + fn resolve_urls(&mut self, source_uri: &Uri) -> Result<(), InstallSettingsError> { + if let Some(ref mut scripts) = self.scripts { + scripts.resolve_urls(source_uri)?; + } + + if let Some(ref mut files) = self.files { + for file in files.iter_mut() { + file.resolve_url(source_uri)?; + } + } + Ok(()) } } diff --git a/rust/agama-lib/src/lib.rs b/rust/agama-lib/src/lib.rs index e7383646c3..59924d4756 100644 --- a/rust/agama-lib/src/lib.rs +++ b/rust/agama-lib/src/lib.rs @@ -1,4 +1,4 @@ -// Copyright (c) [2024] SUSE LLC +// Copyright (c) [2024-2025] SUSE LLC // // All Rights Reserved. // @@ -46,7 +46,9 @@ pub mod auth; pub mod base_http_client; pub mod bootloader; +pub mod context; pub mod error; +pub mod file_source; pub mod files; pub mod hostname; pub mod install_settings; @@ -62,14 +64,14 @@ pub mod storage; pub mod users; // TODO: maybe expose only clients when we have it? pub mod dbus; +pub mod openapi; pub mod progress; pub mod proxies; -mod store; -pub use store::Store; -pub mod openapi; pub mod questions; pub mod scripts; pub mod security; +mod store; +pub use store::Store; pub mod utils; use crate::error::ServiceError; diff --git a/rust/agama-lib/src/scripts/error.rs b/rust/agama-lib/src/scripts/error.rs index fec37981ee..3e9b561fc7 100644 --- a/rust/agama-lib/src/scripts/error.rs +++ b/rust/agama-lib/src/scripts/error.rs @@ -21,7 +21,7 @@ use std::io; use thiserror::Error; -use crate::utils::TransferError; +use crate::{file_source::FileSourceError, utils::TransferError}; #[derive(Error, Debug)] pub enum ScriptError { @@ -31,4 +31,6 @@ pub enum ScriptError { InputOutputError(#[from] io::Error), #[error("Wrong script type")] WrongScriptType, + #[error(transparent)] + FileSourceError(#[from] FileSourceError), } diff --git a/rust/agama-lib/src/scripts/model.rs b/rust/agama-lib/src/scripts/model.rs index a00462321e..a0130b49ba 100644 --- a/rust/agama-lib/src/scripts/model.rs +++ b/rust/agama-lib/src/scripts/model.rs @@ -20,17 +20,30 @@ use std::{ fs, - io::Write, - os::unix::fs::OpenOptionsExt, path::{Path, PathBuf}, process, }; use serde::{Deserialize, Serialize}; -use crate::utils::Transfer; - use super::ScriptError; +use crate::file_source::{FileSource, WithFileSource}; + +macro_rules! impl_with_file_source { + ($struct:ident) => { + impl WithFileSource for $struct { + /// File source. + fn file_source(&self) -> &FileSource { + &self.base.source + } + + /// Mutable file source. + fn file_source_mut(&mut self) -> &mut FileSource { + &mut self.base.source + } + } + }; +} #[derive( Debug, Clone, Copy, PartialEq, strum::Display, Serialize, Deserialize, utoipa::ToSchema, @@ -48,39 +61,21 @@ pub enum ScriptsGroup { pub struct BaseScript { pub name: String, #[serde(flatten)] - pub source: ScriptSource, + pub source: FileSource, } impl BaseScript { + /// Writes the script to the given directory. + /// + /// * `workdir`: directory to write the script to. fn write>(&self, workdir: P) -> Result<(), ScriptError> { let script_path = workdir.as_ref().join(&self.name); std::fs::create_dir_all(script_path.parent().unwrap())?; - - let mut file = fs::OpenOptions::new() - .create(true) - .write(true) - .truncate(true) - .mode(0o500) - .open(&script_path)?; - - match &self.source { - ScriptSource::Text { content } => write!(file, "{}", &content)?, - ScriptSource::Remote { url } => Transfer::get(url, &mut file)?, - }; - + self.source.write(&script_path, 0o700)?; Ok(()) } } -#[derive(Clone, Debug, Serialize, Deserialize, utoipa::ToSchema)] -#[serde(untagged)] -pub enum ScriptSource { - /// Script's content. - Text { content: String }, - /// URL to get the script from. - Remote { url: String }, -} - /// Represents a script to run as part of the installation process. /// /// There are different types of scripts that can run at different stages of the installation. @@ -188,6 +183,8 @@ impl TryFrom