From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [212.224.123.68]) (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange X25519 server-signature RSA-PSS (2048 bits)) (No client certificate requested) by lists.proxmox.com (Postfix) with ESMTPS id 3A514739ED; Fri, 28 May 2021 16:30:57 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 17B74C029; Fri, 28 May 2021 16:30:47 +0200 (CEST) Received: from proxmox-new.maurer-it.com (proxmox-new.maurer-it.com [94.136.29.106]) (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange X25519 server-signature RSA-PSS (2048 bits)) (No client certificate requested) by firstgate.proxmox.com (Proxmox) with ESMTPS id 4ACEBACCB; Fri, 28 May 2021 16:30:12 +0200 (CEST) Received: from proxmox-new.maurer-it.com (localhost.localdomain [127.0.0.1]) by proxmox-new.maurer-it.com (Proxmox) with ESMTP id 1441C4179F; Fri, 28 May 2021 16:30:12 +0200 (CEST) From: Fabian Ebner To: pve-devel@lists.proxmox.com, pbs-devel@lists.proxmox.com Date: Fri, 28 May 2021 16:29:40 +0200 Message-Id: <20210528143002.16190-2-f.ebner@proxmox.com> X-Mailer: git-send-email 2.20.1 In-Reply-To: <20210528143002.16190-1-f.ebner@proxmox.com> References: <20210528143002.16190-1-f.ebner@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-SPAM-LEVEL: Spam detection results: 0 AWL 0.003 Adjusted score from AWL reputation of From: address KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record URIBL_BLOCKED 0.001 ADMINISTRATOR NOTICE: The query to URIBL was blocked. See http://wiki.apache.org/spamassassin/DnsBlocklists#dnsbl-block for more information. [mod.rs, check.rs, proxmox.com, falcot.com, file.rs, strutl.cc, repositories.rs, types.rs, lib.rs, writer.rs] Subject: [pve-devel] [PATCH v5 proxmox-apt 01/23] initial commit X-BeenThere: pve-devel@lists.proxmox.com X-Mailman-Version: 2.1.29 Precedence: list List-Id: Proxmox VE development discussion List-Unsubscribe: , List-Archive: List-Post: List-Help: List-Subscribe: , X-List-Received-Date: Fri, 28 May 2021 14:30:57 -0000 Signed-off-by: Fabian Ebner --- Changes from v4: * clippy fixes: use 'string' instead of '&string[..]' * update proxmox version in Cargo.toml * tests: avoid using a variable only once, but use the string directly .cargo/config | 5 + .gitignore | 4 + Cargo.toml | 23 ++ rustfmt.toml | 1 + src/lib.rs | 3 + src/repositories/check.rs | 47 ++++ src/repositories/file.rs | 96 +++++++ src/repositories/list_parser.rs | 171 ++++++++++++ src/repositories/mod.rs | 224 ++++++++++++++++ src/repositories/sources_parser.rs | 204 +++++++++++++++ src/repositories/writer.rs | 92 +++++++ src/types.rs | 246 ++++++++++++++++++ tests/repositories.rs | 129 +++++++++ .../absolute_suite.list | 5 + .../absolute_suite.sources | 5 + tests/sources.list.d.expected/case.sources | 16 ++ .../sources.list.d.expected/multiline.sources | 10 + .../options_comment.list | 6 + .../pbs-enterprise.list | 2 + tests/sources.list.d.expected/pve.list | 13 + tests/sources.list.d.expected/standard.list | 7 + .../sources.list.d.expected/standard.sources | 11 + tests/sources.list.d/absolute_suite.list | 3 + tests/sources.list.d/absolute_suite.sources | 5 + tests/sources.list.d/case.sources | 17 ++ tests/sources.list.d/multiline.sources | 11 + tests/sources.list.d/options_comment.list | 3 + tests/sources.list.d/pbs-enterprise.list | 1 + tests/sources.list.d/pve.list | 10 + tests/sources.list.d/standard.list | 6 + tests/sources.list.d/standard.sources | 10 + 31 files changed, 1386 insertions(+) create mode 100644 .cargo/config create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 rustfmt.toml create mode 100644 src/lib.rs create mode 100644 src/repositories/check.rs create mode 100644 src/repositories/file.rs create mode 100644 src/repositories/list_parser.rs create mode 100644 src/repositories/mod.rs create mode 100644 src/repositories/sources_parser.rs create mode 100644 src/repositories/writer.rs create mode 100644 src/types.rs create mode 100644 tests/repositories.rs create mode 100644 tests/sources.list.d.expected/absolute_suite.list create mode 100644 tests/sources.list.d.expected/absolute_suite.sources create mode 100644 tests/sources.list.d.expected/case.sources create mode 100644 tests/sources.list.d.expected/multiline.sources create mode 100644 tests/sources.list.d.expected/options_comment.list create mode 100644 tests/sources.list.d.expected/pbs-enterprise.list create mode 100644 tests/sources.list.d.expected/pve.list create mode 100644 tests/sources.list.d.expected/standard.list create mode 100644 tests/sources.list.d.expected/standard.sources create mode 100644 tests/sources.list.d/absolute_suite.list create mode 100644 tests/sources.list.d/absolute_suite.sources create mode 100644 tests/sources.list.d/case.sources create mode 100644 tests/sources.list.d/multiline.sources create mode 100644 tests/sources.list.d/options_comment.list create mode 100644 tests/sources.list.d/pbs-enterprise.list create mode 100644 tests/sources.list.d/pve.list create mode 100644 tests/sources.list.d/standard.list create mode 100644 tests/sources.list.d/standard.sources diff --git a/.cargo/config b/.cargo/config new file mode 100644 index 0000000..3b5b6e4 --- /dev/null +++ b/.cargo/config @@ -0,0 +1,5 @@ +[source] +[source.debian-packages] +directory = "/usr/share/cargo/registry" +[source.crates-io] +replace-with = "debian-packages" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..24917d4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +Cargo.lock +target/ +tests/sources.list.d.actual +tests/sources.list.d.digest diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..24f734b --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "proxmox-apt" +version = "0.1.0" +authors = [ + "Fabian Ebner ", + "Proxmox Support Team ", +] +edition = "2018" +license = "AGPL-3" +description = "Proxmox library for APT" +homepage = "https://www.proxmox.com" + +exclude = [ "debian" ] + +[lib] +name = "proxmox_apt" +path = "src/lib.rs" + +[dependencies] +anyhow = "1.0" +openssl = "0.10" +proxmox = { version = "0.11.5", features = [ "api-macro" ] } +serde = { version = "1.0", features = ["derive"] } diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..32a9786 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1 @@ +edition = "2018" diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..b065c0f --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,3 @@ +pub mod types; + +pub mod repositories; diff --git a/src/repositories/check.rs b/src/repositories/check.rs new file mode 100644 index 0000000..87fbbac --- /dev/null +++ b/src/repositories/check.rs @@ -0,0 +1,47 @@ +use anyhow::{bail, Error}; + +use crate::types::{APTRepository, APTRepositoryFileType}; + +impl APTRepository { + /// Makes sure that all basic properties of a repository are present and + /// not obviously invalid. + pub fn basic_check(&self) -> Result<(), Error> { + if self.types.is_empty() { + bail!("missing package type(s)"); + } + if self.uris.is_empty() { + bail!("missing URI(s)"); + } + if self.suites.is_empty() { + bail!("missing suite(s)"); + } + + for uri in self.uris.iter() { + if !uri.contains(':') || uri.len() < 3 { + bail!("invalid URI: '{}'", uri); + } + } + + for suite in self.suites.iter() { + if !suite.ends_with('/') && self.components.is_empty() { + bail!("missing component(s)"); + } else if suite.ends_with('/') && !self.components.is_empty() { + bail!("absolute suite '{}' does not allow component(s)", suite); + } + } + + if self.file_type == APTRepositoryFileType::List { + if self.types.len() > 1 { + bail!("more than one package type"); + } + if self.uris.len() > 1 { + bail!("more than one URI"); + } + if self.suites.len() > 1 { + bail!("more than one suite"); + } + } + + Ok(()) + } +} diff --git a/src/repositories/file.rs b/src/repositories/file.rs new file mode 100644 index 0000000..e264ec6 --- /dev/null +++ b/src/repositories/file.rs @@ -0,0 +1,96 @@ +use std::convert::TryFrom; +use std::path::{Path, PathBuf}; + +use anyhow::{format_err, Error}; + +use crate::types::{APTRepositoryFile, APTRepositoryFileError, APTRepositoryFileType}; + +impl APTRepositoryFile { + /// Creates a new `APTRepositoryFile` without parsing. + /// + /// If the file is hidden or the path points to a directory, `Ok(None)` is + /// returned, while invalid file names yield an error. + pub fn new>(path: P) -> Result, APTRepositoryFileError> { + let path: PathBuf = path.as_ref().to_path_buf(); + + let new_err = |path_string: String, err: &str| APTRepositoryFileError { + path: path_string, + error: err.to_string(), + }; + + let path_string = path + .clone() + .into_os_string() + .into_string() + .map_err(|os_string| { + new_err( + os_string.to_string_lossy().to_string(), + "path is not valid unicode", + ) + })?; + + let new_err = |err| new_err(path_string.clone(), err); + + if path.is_dir() { + return Ok(None); + } + + let file_name = match path.file_name() { + Some(file_name) => file_name + .to_os_string() + .into_string() + .map_err(|_| new_err("invalid path"))?, + None => return Err(new_err("invalid path")), + }; + + if file_name.starts_with('.') { + return Ok(None); + } + + let extension = match path.extension() { + Some(extension) => extension + .to_os_string() + .into_string() + .map_err(|_| new_err("invalid path"))?, + None => return Err(new_err("invalid extension")), + }; + + let file_type = APTRepositoryFileType::try_from(&extension[..]) + .map_err(|_| new_err("invalid extension"))?; + + if !file_name + .chars() + .all(|x| x.is_ascii_alphanumeric() || x == '_' || x == '-' || x == '.') + { + return Err(new_err("invalid characters in file name")); + } + + Ok(Some(Self { + path: path_string, + file_type, + repositories: vec![], + digest: None, + })) + } + + /// Check if the file exists. + pub fn exists(&self) -> bool { + PathBuf::from(&self.path).exists() + } + + pub fn read_with_digest(&self) -> Result<(Vec, [u8; 32]), APTRepositoryFileError> { + let content = std::fs::read(&self.path).map_err(|err| self.err(format_err!("{}", err)))?; + + let digest = openssl::sha::sha256(&content); + + Ok((content, digest)) + } + + /// Create an `APTRepositoryFileError`. + pub fn err(&self, error: Error) -> APTRepositoryFileError { + APTRepositoryFileError { + path: self.path.clone(), + error: error.to_string(), + } + } +} diff --git a/src/repositories/list_parser.rs b/src/repositories/list_parser.rs new file mode 100644 index 0000000..6c9f898 --- /dev/null +++ b/src/repositories/list_parser.rs @@ -0,0 +1,171 @@ +use std::convert::TryInto; +use std::io::BufRead; +use std::iter::{Iterator, Peekable}; +use std::str::SplitAsciiWhitespace; + +use anyhow::{bail, format_err, Error}; + +use super::APTRepositoryParser; +use crate::types::{APTRepository, APTRepositoryFileType, APTRepositoryOption}; + +pub struct APTListFileParser { + input: R, + line_nr: usize, + comment: String, +} + +impl APTListFileParser { + pub fn new(reader: R) -> Self { + Self { + input: reader, + line_nr: 0, + comment: String::new(), + } + } + + /// Helper to parse options from the existing token stream. + /// + /// Also returns `Ok(())` if there are no options. + /// + /// Errors when options are invalid or not closed by `']'`. + fn parse_options( + options: &mut Vec, + tokens: &mut Peekable, + ) -> Result<(), Error> { + let mut option = match tokens.peek() { + Some(token) => { + match token.strip_prefix('[') { + Some(option) => option, + None => return Ok(()), // doesn't look like options + } + } + None => return Ok(()), + }; + + tokens.next(); // avoid reading the beginning twice + + let mut finished = false; + loop { + if let Some(stripped) = option.strip_suffix(']') { + option = stripped; + if option.is_empty() { + break; + } + finished = true; // but still need to handle the last one + }; + + if let Some(mid) = option.find('=') { + let (key, mut value_str) = option.split_at(mid); + value_str = &value_str[1..]; + + if key.is_empty() { + bail!("option has no key: '{}'", option); + } + + if value_str.is_empty() { + bail!("option has no value: '{}'", option); + } + + let values: Vec = value_str + .split(',') + .map(|value| value.to_string()) + .collect(); + + options.push(APTRepositoryOption { + key: key.to_string(), + values, + }); + } else if !option.is_empty() { + bail!("got invalid option - '{}'", option); + } + + if finished { + break; + } + + option = match tokens.next() { + Some(option) => option, + None => bail!("options not closed by ']'"), + } + } + + Ok(()) + } + + /// Parse a repository or comment in one-line format. + /// + /// Commented out repositories are also detected and returned with the + /// `enabled` property set to `false`. + /// + /// If the line contains a repository, `self.comment` is added to the + /// `comment` property. + /// + /// If the line contains a comment, it is added to `self.comment`. + fn parse_one_line(&mut self, mut line: &str) -> Result, Error> { + line = line.trim_matches(|c| char::is_ascii_whitespace(&c)); + + // check for commented out repository first + if let Some(commented_out) = line.strip_prefix('#') { + if let Ok(Some(mut repo)) = self.parse_one_line(commented_out) { + repo.set_enabled(false); + return Ok(Some(repo)); + } + } + + let mut repo = APTRepository::new(APTRepositoryFileType::List); + + // now handle "real" comment + if let Some(comment_start) = line.find('#') { + let (line_start, comment) = line.split_at(comment_start); + self.comment = format!("{}{}\n", self.comment, &comment[1..]); + line = line_start; + } + + let mut tokens = line.split_ascii_whitespace().peekable(); + + match tokens.next() { + Some(package_type) => { + repo.types.push(package_type.try_into()?); + } + None => return Ok(None), // empty line + } + + Self::parse_options(&mut repo.options, &mut tokens)?; + + // the rest of the line is just ' [...]' + let mut tokens = tokens.map(str::to_string); + repo.uris + .push(tokens.next().ok_or_else(|| format_err!("missing URI"))?); + repo.suites + .push(tokens.next().ok_or_else(|| format_err!("missing suite"))?); + repo.components.extend(tokens); + + repo.comment = std::mem::take(&mut self.comment); + + Ok(Some(repo)) + } +} + +impl APTRepositoryParser for APTListFileParser { + fn parse_repositories(&mut self) -> Result, Error> { + let mut repos = vec![]; + let mut line = String::new(); + + loop { + self.line_nr += 1; + line.clear(); + + match self.input.read_line(&mut line) { + Err(err) => bail!("input error - {}", err), + Ok(0) => break, + Ok(_) => match self.parse_one_line(&line) { + Ok(Some(repo)) => repos.push(repo), + Ok(None) => continue, + Err(err) => bail!("malformed entry on line {} - {}", self.line_nr, err), + }, + } + } + + Ok(repos) + } +} diff --git a/src/repositories/mod.rs b/src/repositories/mod.rs new file mode 100644 index 0000000..187ead3 --- /dev/null +++ b/src/repositories/mod.rs @@ -0,0 +1,224 @@ +use std::path::PathBuf; + +use anyhow::{bail, format_err, Error}; + +use crate::types::{ + APTRepository, APTRepositoryFile, APTRepositoryFileError, APTRepositoryFileType, + APTRepositoryOption, +}; + +mod list_parser; +use list_parser::APTListFileParser; + +mod sources_parser; +use sources_parser::APTSourcesFileParser; + +mod check; +mod file; +mod writer; + +const APT_SOURCES_LIST_FILENAME: &str = "/etc/apt/sources.list"; +const APT_SOURCES_LIST_DIRECTORY: &str = "/etc/apt/sources.list.d/"; + +impl APTRepository { + /// Crates an empty repository. + fn new(file_type: APTRepositoryFileType) -> Self { + Self { + types: vec![], + uris: vec![], + suites: vec![], + components: vec![], + options: vec![], + comment: String::new(), + file_type, + enabled: true, + } + } + + /// Changes the `enabled` flag and makes sure the `Enabled` option for + /// `APTRepositoryPackageType::Sources` repositories is updated too. + fn set_enabled(&mut self, enabled: bool) { + self.enabled = enabled; + + if self.file_type == APTRepositoryFileType::Sources { + let enabled_string = match enabled { + true => "true".to_string(), + false => "false".to_string(), + }; + for option in self.options.iter_mut() { + if option.key == "Enabled" { + option.values = vec![enabled_string]; + return; + } + } + self.options.push(APTRepositoryOption { + key: "Enabled".to_string(), + values: vec![enabled_string], + }); + } + } +} + +trait APTRepositoryParser { + /// Parse all repositories including the disabled ones and push them onto + /// the provided vector. + fn parse_repositories(&mut self) -> Result, Error>; +} + +impl APTRepositoryFile { + /// Parses the APT repositories configured in the file on disk, including + /// disabled ones. + /// + /// Resets the current repositories and digest, even on failure. + pub fn parse(&mut self) -> Result<(), APTRepositoryFileError> { + self.repositories.clear(); + self.digest = None; + + let (content, digest) = self.read_with_digest()?; + + let mut parser: Box = match self.file_type { + APTRepositoryFileType::List => Box::new(APTListFileParser::new(&content[..])), + APTRepositoryFileType::Sources => Box::new(APTSourcesFileParser::new(&content[..])), + }; + + let repos = parser.parse_repositories().map_err(|err| self.err(err))?; + + for (n, repo) in repos.iter().enumerate() { + repo.basic_check() + .map_err(|err| self.err(format_err!("check for repository {} - {}", n + 1, err)))?; + } + + self.repositories = repos; + self.digest = Some(digest); + + Ok(()) + } + + /// Writes the repositories to the file on disk. + /// + /// If a digest is provided, checks that the current content of the file still + /// produces the same one. + pub fn write(&self) -> Result<(), APTRepositoryFileError> { + if let Some(digest) = self.digest { + if !self.exists() { + return Err(self.err(format_err!("digest specified, but file does not exist"))); + } + + let (_, current_digest) = self.read_with_digest()?; + if digest != current_digest { + return Err(self.err(format_err!("digest mismatch"))); + } + } + + let mut content = vec![]; + + for (n, repo) in self.repositories.iter().enumerate() { + repo.basic_check() + .map_err(|err| self.err(format_err!("check for repository {} - {}", n + 1, err)))?; + + repo.write(&mut content) + .map_err(|err| self.err(format_err!("writing repository {} - {}", n + 1, err)))?; + } + + let path = PathBuf::from(&self.path); + let dir = match path.parent() { + Some(dir) => dir, + None => return Err(self.err(format_err!("invalid path"))), + }; + + std::fs::create_dir_all(dir) + .map_err(|err| self.err(format_err!("unable to create parent dir - {}", err)))?; + + let pid = std::process::id(); + let mut tmp_path = path.clone(); + tmp_path.set_extension("tmp"); + tmp_path.set_extension(format!("{}", pid)); + + if let Err(err) = std::fs::write(&tmp_path, content) { + let _ = std::fs::remove_file(&tmp_path); + return Err(self.err(format_err!("writing {:?} failed - {}", path, err))); + } + + if let Err(err) = std::fs::rename(&tmp_path, &path) { + let _ = std::fs::remove_file(&tmp_path); + return Err(self.err(format_err!("rename failed for {:?} - {}", path, err))); + } + + Ok(()) + } +} + +/// Returns all APT repositories configured in `/etc/apt/sources.list` and +/// in `/etc/apt/sources.list.d` including disabled repositories. +/// +/// Returns the parsable files with their repositories and a list of errors for +/// files that could not be read or parsed. +/// +/// The digest is guaranteed to be set for each successfully parsed file. +pub fn repositories() -> Result<(Vec, Vec), Error> { + let mut files = vec![]; + let mut errors = vec![]; + + let sources_list_path = PathBuf::from(APT_SOURCES_LIST_FILENAME); + + let sources_list_d_path = PathBuf::from(APT_SOURCES_LIST_DIRECTORY); + + match APTRepositoryFile::new(sources_list_path) { + Ok(Some(mut file)) => match file.parse() { + Ok(()) => files.push(file), + Err(err) => errors.push(err), + }, + _ => bail!("internal error with '{}'", APT_SOURCES_LIST_FILENAME), + } + + if !sources_list_d_path.exists() { + return Ok((files, errors)); + } + + if !sources_list_d_path.is_dir() { + errors.push(APTRepositoryFileError { + path: APT_SOURCES_LIST_DIRECTORY.to_string(), + error: "not a directory!".to_string(), + }); + return Ok((files, errors)); + } + + for entry in std::fs::read_dir(sources_list_d_path)? { + let path = entry?.path(); + + match APTRepositoryFile::new(path) { + Ok(Some(mut file)) => match file.parse() { + Ok(()) => { + if file.digest.is_none() { + bail!("internal error - digest not set"); + } + files.push(file); + } + Err(err) => errors.push(err), + }, + Ok(None) => (), + Err(err) => errors.push(err), + } + } + + Ok((files, errors)) +} + +/// Write the repositories for each file. +/// +/// Returns an error for each file that could not be written successfully. +pub fn write_repositories(files: &[APTRepositoryFile]) -> Result<(), Vec> { + let mut errors = vec![]; + + for file in files { + if let Err(err) = file.write() { + errors.push(err); + } + } + + if !errors.is_empty() { + return Err(errors); + } + + Ok(()) +} diff --git a/src/repositories/sources_parser.rs b/src/repositories/sources_parser.rs new file mode 100644 index 0000000..a056b8f --- /dev/null +++ b/src/repositories/sources_parser.rs @@ -0,0 +1,204 @@ +use std::convert::TryInto; +use std::io::BufRead; +use std::iter::Iterator; + +use anyhow::{bail, Error}; + +use crate::types::{ + APTRepository, APTRepositoryFileType, APTRepositoryOption, APTRepositoryPackageType, +}; + +use super::APTRepositoryParser; + +pub struct APTSourcesFileParser { + input: R, + stanza_nr: usize, + comment: String, +} + +/// See `man sources.list` and `man deb822` for the format specification. +impl APTSourcesFileParser { + pub fn new(reader: R) -> Self { + Self { + input: reader, + stanza_nr: 1, + comment: String::new(), + } + } + + /// Based on APT's `StringToBool` in `strutl.cc` + fn string_to_bool(string: &str, default: bool) -> bool { + let string = string.trim_matches(|c| char::is_ascii_whitespace(&c)); + let string = string.to_lowercase(); + + match &string[..] { + "1" | "yes" | "true" | "with" | "on" | "enable" => true, + "0" | "no" | "false" | "without" | "off" | "disable" => false, + _ => default, + } + } + + /// Checks if `key` is valid according to deb822 + fn valid_key(key: &str) -> bool { + if key.starts_with('-') { + return false; + }; + return key.chars().all(|c| matches!(c, '!'..='9' | ';'..='~')); + } + + /// Try parsing a repository in stanza format from `lines`. + /// + /// Returns `Ok(None)` when no stanza can be found. + /// + /// Comments are added to `self.comments`. If a stanza can be found, + /// `self.comment` is added to the repository's `comment` property. + /// + /// Fully commented out stanzas are treated as comments. + fn parse_stanza(&mut self, lines: &str) -> Result, Error> { + let mut repo = APTRepository::new(APTRepositoryFileType::Sources); + + // Values may be folded into multiple lines. + // Those lines have to start with a space or a tab. + let lines = lines.replace("\n ", " "); + let lines = lines.replace("\n\t", " "); + + let mut got_something = false; + + for line in lines.lines() { + let line = line.trim_matches(|c| char::is_ascii_whitespace(&c)); + if line.is_empty() { + continue; + } + + if let Some(commented_out) = line.strip_prefix('#') { + self.comment = format!("{}{}\n", self.comment, commented_out); + continue; + } + + if let Some(mid) = line.find(':') { + let (key, value_str) = line.split_at(mid); + let value_str = &value_str[1..]; + let key = key.trim_matches(|c| char::is_ascii_whitespace(&c)); + + if key.is_empty() { + bail!("option has no key: '{}'", line); + } + + if value_str.is_empty() { + // ignored by APT + eprintln!("option has no value: '{}'", line); + continue; + } + + if !Self::valid_key(key) { + // ignored by APT + eprintln!("option with invalid key '{}'", key); + continue; + } + + let values: Vec = value_str + .split_ascii_whitespace() + .map(|value| value.to_string()) + .collect(); + + match &key.to_lowercase()[..] { + "types" => { + if !repo.types.is_empty() { + eprintln!("key 'Types' was defined twice"); + } + let mut types = Vec::::new(); + for package_type in values { + types.push((&package_type[..]).try_into()?); + } + repo.types = types; + } + "uris" => { + if !repo.uris.is_empty() { + eprintln!("key 'URIs' was defined twice"); + } + repo.uris = values; + } + "suites" => { + if !repo.suites.is_empty() { + eprintln!("key 'Suites' was defined twice"); + } + repo.suites = values; + } + "components" => { + if !repo.components.is_empty() { + eprintln!("key 'Components' was defined twice"); + } + repo.components = values; + } + "enabled" => { + repo.set_enabled(Self::string_to_bool(value_str, true)); + } + _ => repo.options.push(APTRepositoryOption { + key: key.to_string(), + values, + }), + } + } else { + bail!("got invalid line - '{:?}'", line); + } + + got_something = true; + } + + if !got_something { + return Ok(None); + } + + repo.comment = std::mem::take(&mut self.comment); + + Ok(Some(repo)) + } + + /// Helper function for `parse_repositories`. + fn try_parse_stanza( + &mut self, + lines: &str, + repos: &mut Vec, + ) -> Result<(), Error> { + match self.parse_stanza(lines) { + Ok(Some(repo)) => { + repos.push(repo); + self.stanza_nr += 1; + } + Ok(None) => (), + Err(err) => bail!("malformed entry in stanza {} - {}", self.stanza_nr, err), + } + + Ok(()) + } +} + +impl APTRepositoryParser for APTSourcesFileParser { + fn parse_repositories(&mut self) -> Result, Error> { + let mut repos = vec![]; + let mut lines = String::new(); + + loop { + let old_length = lines.len(); + match self.input.read_line(&mut lines) { + Err(err) => bail!("input error - {}", err), + Ok(0) => { + self.try_parse_stanza(&lines[..], &mut repos)?; + break; + } + Ok(_) => { + if (&lines[old_length..]) + .trim_matches(|c| char::is_ascii_whitespace(&c)) + .is_empty() + { + // detected end of stanza + self.try_parse_stanza(&lines[..], &mut repos)?; + lines.clear(); + } + } + } + } + + Ok(repos) + } +} diff --git a/src/repositories/writer.rs b/src/repositories/writer.rs new file mode 100644 index 0000000..d9e937c --- /dev/null +++ b/src/repositories/writer.rs @@ -0,0 +1,92 @@ +use std::io::Write; + +use anyhow::{bail, Error}; + +use crate::types::{APTRepository, APTRepositoryFileType}; + +impl APTRepository { + /// Writes a repository in the corresponding format followed by a blank. + /// + /// Expects that `basic_check()` for the repository was successful. + pub fn write(&self, w: &mut dyn Write) -> Result<(), Error> { + match self.file_type { + APTRepositoryFileType::List => write_one_line(self, w), + APTRepositoryFileType::Sources => write_stanza(self, w), + } + } +} + +/// Writes a repository in one-line format followed by a blank line. +/// +/// Expects that `repo.file_type == APTRepositoryFileType::List`. +/// +/// Expects that `basic_check()` for the repository was successful. +fn write_one_line(repo: &APTRepository, w: &mut dyn Write) -> Result<(), Error> { + if repo.file_type != APTRepositoryFileType::List { + bail!("not a .list repository"); + } + + if !repo.comment.is_empty() { + for line in repo.comment.lines() { + writeln!(w, "#{}", line)?; + } + } + + if !repo.enabled { + write!(w, "# ")?; + } + + write!(w, "{} ", repo.types[0])?; + + if !repo.options.is_empty() { + write!(w, "[ ")?; + repo.options + .iter() + .try_for_each(|option| write!(w, "{}={} ", option.key, option.values.join(",")))?; + write!(w, "] ")?; + }; + + write!(w, "{} ", repo.uris[0])?; + write!(w, "{} ", repo.suites[0])?; + writeln!(w, "{}", repo.components.join(" "))?; + + writeln!(w)?; + + Ok(()) +} + +/// Writes a single stanza followed by a blank line. +/// +/// Expects that `repo.file_type == APTRepositoryFileType::Sources`. +fn write_stanza(repo: &APTRepository, w: &mut dyn Write) -> Result<(), Error> { + if repo.file_type != APTRepositoryFileType::Sources { + bail!("not a .sources repository"); + } + + if !repo.comment.is_empty() { + for line in repo.comment.lines() { + writeln!(w, "#{}", line)?; + } + } + + write!(w, "Types:")?; + repo.types + .iter() + .try_for_each(|package_type| write!(w, " {}", package_type))?; + writeln!(w)?; + + writeln!(w, "URIs: {}", repo.uris.join(" "))?; + writeln!(w, "Suites: {}", repo.suites.join(" "))?; + + if !repo.components.is_empty() { + writeln!(w, "Components: {}", repo.components.join(" "))?; + } + + for option in repo.options.iter() { + writeln!(w, "{}: {}", option.key, option.values.join(" "))?; + } + + writeln!(w)?; + + Ok(()) +} diff --git a/src/types.rs b/src/types.rs new file mode 100644 index 0000000..bbd8e7e --- /dev/null +++ b/src/types.rs @@ -0,0 +1,246 @@ +use std::convert::TryFrom; +use std::fmt::Display; + +use anyhow::{bail, Error}; +use serde::{Deserialize, Serialize}; + +use proxmox::api::api; + +#[api] +#[derive(Debug, Copy, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum APTRepositoryFileType { + /// One-line-style format + List, + /// DEB822-style format + Sources, +} + +impl TryFrom<&str> for APTRepositoryFileType { + type Error = Error; + + fn try_from(string: &str) -> Result { + match string { + "list" => Ok(APTRepositoryFileType::List), + "sources" => Ok(APTRepositoryFileType::Sources), + _ => bail!("invalid file type '{}'", string), + } + } +} + +impl Display for APTRepositoryFileType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + APTRepositoryFileType::List => write!(f, "list"), + APTRepositoryFileType::Sources => write!(f, "sources"), + } + } +} + +#[api] +#[derive(Debug, Copy, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "kebab-case")] +pub enum APTRepositoryPackageType { + /// Debian package + Deb, + /// Debian source package + DebSrc, +} + +impl TryFrom<&str> for APTRepositoryPackageType { + type Error = Error; + + fn try_from(string: &str) -> Result { + match string { + "deb" => Ok(APTRepositoryPackageType::Deb), + "deb-src" => Ok(APTRepositoryPackageType::DebSrc), + _ => bail!("invalid package type '{}'", string), + } + } +} + +impl Display for APTRepositoryPackageType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + APTRepositoryPackageType::Deb => write!(f, "deb"), + APTRepositoryPackageType::DebSrc => write!(f, "deb-src"), + } + } +} + +#[api( + properties: { + Key: { + description: "Option key.", + type: String, + }, + Values: { + description: "Option values.", + type: Array, + items: { + description: "Value.", + type: String, + }, + }, + }, +)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "PascalCase")] // for consistency +/// Additional options for an APT repository. +/// Used for both single- and mutli-value options. +pub struct APTRepositoryOption { + /// Option key. + pub key: String, + /// Option value(s). + pub values: Vec, +} + +#[api( + properties: { + Types: { + description: "List of package types.", + type: Array, + items: { + type: APTRepositoryPackageType, + }, + }, + URIs: { + description: "List of repository URIs.", + type: Array, + items: { + description: "Repository URI.", + type: String, + }, + }, + Suites: { + description: "List of distributions.", + type: Array, + items: { + description: "Package distribution.", + type: String, + }, + }, + Components: { + description: "List of repository components.", + type: Array, + items: { + description: "Repository component.", + type: String, + }, + }, + Options: { + type: Array, + optional: true, + items: { + type: APTRepositoryOption, + }, + }, + Comment: { + description: "Associated comment.", + type: String, + optional: true, + }, + FileType: { + type: APTRepositoryFileType, + }, + Enabled: { + description: "Whether the repository is enabled or not.", + type: Boolean, + }, + }, +)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "PascalCase")] +/// Describes an APT repository. +pub struct APTRepository { + /// List of package types. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub types: Vec, + + /// List of repository URIs. + #[serde(skip_serializing_if = "Vec::is_empty")] + #[serde(rename = "URIs")] + pub uris: Vec, + + /// List of package distributions. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub suites: Vec, + + /// List of repository components. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub components: Vec, + + /// Additional options. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub options: Vec, + + /// Associated comment. + #[serde(skip_serializing_if = "String::is_empty")] + pub comment: String, + + /// Format of the defining file. + pub file_type: APTRepositoryFileType, + + /// Whether the repository is enabled or not. + pub enabled: bool, +} + +#[api( + properties: { + file_type: { + type: APTRepositoryFileType, + }, + repositories: { + description: "List of APT repositories.", + type: Array, + items: { + type: APTRepository, + }, + }, + digest: { + description: "Digest for the content of the file.", + optional: true, + type: Array, + items: { + description: "Digest byte.", + type: Integer, + }, + }, + }, +)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +/// Represents an abstract APT repository file. +pub struct APTRepositoryFile { + /// The path to the file. + pub path: String, + /// The type of the file. + pub file_type: APTRepositoryFileType, + /// List of repositories in the file. + pub repositories: Vec, + /// Digest of the original contents. + pub digest: Option<[u8; 32]>, +} + +#[api] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +/// Error type for problems with APT repository files. +pub struct APTRepositoryFileError { + /// The path to the problematic file. + pub path: String, + /// The error message. + pub error: String, +} + +impl Display for APTRepositoryFileError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "proxmox-apt error for '{}' - {}", self.path, self.error) + } +} + +impl std::error::Error for APTRepositoryFileError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + None + } +} diff --git a/tests/repositories.rs b/tests/repositories.rs new file mode 100644 index 0000000..aca05ef --- /dev/null +++ b/tests/repositories.rs @@ -0,0 +1,129 @@ +use std::path::PathBuf; + +use anyhow::{bail, format_err, Error}; + +use proxmox_apt::repositories::write_repositories; +use proxmox_apt::types::APTRepositoryFile; + +#[test] +fn test_parse_write() -> Result<(), Error> { + let test_dir = std::env::current_dir()?.join("tests"); + let read_dir = test_dir.join("sources.list.d"); + let write_dir = test_dir.join("sources.list.d.actual"); + let expected_dir = test_dir.join("sources.list.d.expected"); + + if write_dir.is_dir() { + std::fs::remove_dir_all(&write_dir) + .map_err(|err| format_err!("unable to remove dir {:?} - {}", write_dir, err))?; + } + + std::fs::create_dir_all(&write_dir) + .map_err(|err| format_err!("unable to create dir {:?} - {}", write_dir, err))?; + + let mut files = vec![]; + let mut errors = vec![]; + + for entry in std::fs::read_dir(read_dir)? { + let path = entry?.path(); + + match APTRepositoryFile::new(&path)? { + Some(mut file) => match file.parse() { + Ok(()) => files.push(file), + Err(err) => errors.push(err), + }, + None => bail!("unexpected None for '{:?}'", path), + } + } + + assert!(errors.is_empty()); + + for file in files.iter_mut() { + let path = PathBuf::from(&file.path); + let new_path = write_dir.join(path.file_name().unwrap()); + file.path = new_path.into_os_string().into_string().unwrap(); + file.digest = None; + } + + write_repositories(&files).map_err(|err| format_err!("{:?}", err))?; + + let mut expected_count = 0; + + for entry in std::fs::read_dir(expected_dir)? { + expected_count += 1; + + let expected_path = entry?.path(); + let actual_path = write_dir.join(expected_path.file_name().unwrap()); + + let expected_contents = std::fs::read(&expected_path) + .map_err(|err| format_err!("unable to read {:?} - {}", expected_path, err))?; + + let actual_contents = std::fs::read(&actual_path) + .map_err(|err| format_err!("unable to read {:?} - {}", actual_path, err))?; + + assert_eq!( + expected_contents, actual_contents, + "Use\n\ndiff {:?} {:?}\n\nif you're not fluent in byte decimals", + expected_path, actual_path + ); + } + + let actual_count = std::fs::read_dir(write_dir)?.count(); + + assert_eq!(expected_count, actual_count); + + Ok(()) +} + +#[test] +fn test_digest() -> Result<(), Error> { + let test_dir = std::env::current_dir()?.join("tests"); + let read_dir = test_dir.join("sources.list.d"); + let write_dir = test_dir.join("sources.list.d.digest"); + + if write_dir.is_dir() { + std::fs::remove_dir_all(&write_dir) + .map_err(|err| format_err!("unable to remove dir {:?} - {}", write_dir, err))?; + } + + std::fs::create_dir_all(&write_dir) + .map_err(|err| format_err!("unable to create dir {:?} - {}", write_dir, err))?; + + let path = read_dir.join("standard.list"); + + let mut file = APTRepositoryFile::new(&path)?.unwrap(); + file.parse()?; + + let new_path = write_dir.join(path.file_name().unwrap()); + file.path = new_path.clone().into_os_string().into_string().unwrap(); + + let old_digest = file.digest.unwrap(); + let mut files = vec![file]; + + // file does not exist yet... + assert!(files.first().unwrap().read_with_digest().is_err()); + assert!(write_repositories(&files).is_err()); + + // ...but it should work if there's no digest + files[0].digest = None; + write_repositories(&files).map_err(|err| format_err!("{:?}", err))?; + + // overwrite with old contents... + std::fs::copy(path, new_path)?; + + // modify the repo + let mut file = files.first_mut().unwrap(); + let mut repo = file.repositories.first_mut().unwrap(); + repo.enabled = !repo.enabled; + + // ...then it should work + file.digest = Some(old_digest); + write_repositories(&files).map_err(|err| format_err!("{:?}", err))?; + + // expect a different digest, because the repo was modified + let (_, new_digest) = files.first().unwrap().read_with_digest()?; + assert_ne!(old_digest, new_digest); + + assert!(write_repositories(&files).is_err()); + + Ok(()) +} diff --git a/tests/sources.list.d.expected/absolute_suite.list b/tests/sources.list.d.expected/absolute_suite.list new file mode 100644 index 0000000..525389c --- /dev/null +++ b/tests/sources.list.d.expected/absolute_suite.list @@ -0,0 +1,5 @@ +# From Debian Administrator's Handbook +deb http://packages.falcot.com/ updates/ + +deb http://packages.falcot.com/ internal/ + diff --git a/tests/sources.list.d.expected/absolute_suite.sources b/tests/sources.list.d.expected/absolute_suite.sources new file mode 100644 index 0000000..51e4d56 --- /dev/null +++ b/tests/sources.list.d.expected/absolute_suite.sources @@ -0,0 +1,5 @@ +# From Debian Administrator's Handbook +Types: deb +URIs: http://packages.falcot.com/ +Suites: updates/ internal/ + diff --git a/tests/sources.list.d.expected/case.sources b/tests/sources.list.d.expected/case.sources new file mode 100644 index 0000000..307aab6 --- /dev/null +++ b/tests/sources.list.d.expected/case.sources @@ -0,0 +1,16 @@ +# comment in here +Types: deb deb-src +URIs: http://ftp.at.debian.org/debian +Suites: buster-updates +Components: main contrib +languages: it de fr +Enabled: false +languages-Add: ja +languages-Remove: de + +# comment in here +Types: deb deb-src +URIs: http://ftp.at.debian.org/debian +Suites: buster +Components: main contrib + diff --git a/tests/sources.list.d.expected/multiline.sources b/tests/sources.list.d.expected/multiline.sources new file mode 100644 index 0000000..d96acea --- /dev/null +++ b/tests/sources.list.d.expected/multiline.sources @@ -0,0 +1,10 @@ +# comment in here +Types: deb deb-src +URIs: http://ftp.at.debian.org/debian +Suites: buster buster-updates +Components: main contrib +Languages: it de fr +Enabled: false +Languages-Add: ja +Languages-Remove: de + diff --git a/tests/sources.list.d.expected/options_comment.list b/tests/sources.list.d.expected/options_comment.list new file mode 100644 index 0000000..8c905c0 --- /dev/null +++ b/tests/sources.list.d.expected/options_comment.list @@ -0,0 +1,6 @@ +# comment +deb [ lang=it,de arch=amd64 ] http://ftp.at.debian.org/debian buster main contrib + +# non-free :( +deb [ lang=it,de arch=amd64 lang+=fr lang-=de ] http://ftp.at.debian.org/debian buster non-free + diff --git a/tests/sources.list.d.expected/pbs-enterprise.list b/tests/sources.list.d.expected/pbs-enterprise.list new file mode 100644 index 0000000..acb2990 --- /dev/null +++ b/tests/sources.list.d.expected/pbs-enterprise.list @@ -0,0 +1,2 @@ +deb https://enterprise.proxmox.com/debian/pbs buster pbs-enterprise + diff --git a/tests/sources.list.d.expected/pve.list b/tests/sources.list.d.expected/pve.list new file mode 100644 index 0000000..127a49a --- /dev/null +++ b/tests/sources.list.d.expected/pve.list @@ -0,0 +1,13 @@ +deb http://ftp.debian.org/debian buster main contrib + +deb http://ftp.debian.org/debian buster-updates main contrib + +# PVE pve-no-subscription repository provided by proxmox.com, +# NOT recommended for production use +deb http://download.proxmox.com/debian/pve buster pve-no-subscription + +# deb https://enterprise.proxmox.com/debian/pve buster pve-enterprise + +# security updates +deb http://security.debian.org/debian-security buster/updates main contrib + diff --git a/tests/sources.list.d.expected/standard.list b/tests/sources.list.d.expected/standard.list new file mode 100644 index 0000000..63c1b60 --- /dev/null +++ b/tests/sources.list.d.expected/standard.list @@ -0,0 +1,7 @@ +deb http://ftp.at.debian.org/debian buster main contrib + +deb http://ftp.at.debian.org/debian buster-updates main contrib + +# security updates +deb http://security.debian.org buster/updates main contrib + diff --git a/tests/sources.list.d.expected/standard.sources b/tests/sources.list.d.expected/standard.sources new file mode 100644 index 0000000..56ce280 --- /dev/null +++ b/tests/sources.list.d.expected/standard.sources @@ -0,0 +1,11 @@ +Types: deb +URIs: http://ftp.at.debian.org/debian +Suites: buster buster-updates +Components: main contrib + +# security updates +Types: deb +URIs: http://security.debian.org +Suites: buster/updates +Components: main contrib + diff --git a/tests/sources.list.d/absolute_suite.list b/tests/sources.list.d/absolute_suite.list new file mode 100644 index 0000000..ed576bf --- /dev/null +++ b/tests/sources.list.d/absolute_suite.list @@ -0,0 +1,3 @@ +# From Debian Administrator's Handbook +deb http://packages.falcot.com/ updates/ +deb http://packages.falcot.com/ internal/ diff --git a/tests/sources.list.d/absolute_suite.sources b/tests/sources.list.d/absolute_suite.sources new file mode 100644 index 0000000..51e4d56 --- /dev/null +++ b/tests/sources.list.d/absolute_suite.sources @@ -0,0 +1,5 @@ +# From Debian Administrator's Handbook +Types: deb +URIs: http://packages.falcot.com/ +Suites: updates/ internal/ + diff --git a/tests/sources.list.d/case.sources b/tests/sources.list.d/case.sources new file mode 100644 index 0000000..8979d0c --- /dev/null +++ b/tests/sources.list.d/case.sources @@ -0,0 +1,17 @@ +tYpeS: deb deb-src +uRis: http://ftp.at.debian.org/debian +suiTes: buster-updates +# comment in here +CompOnentS: main contrib +languages: it + de + fr +Enabled: off +languages-Add: ja +languages-Remove: de + +types: deb deb-src +Uris: http://ftp.at.debian.org/debian +suites: buster +# comment in here +components: main contrib diff --git a/tests/sources.list.d/multiline.sources b/tests/sources.list.d/multiline.sources new file mode 100644 index 0000000..bdbce29 --- /dev/null +++ b/tests/sources.list.d/multiline.sources @@ -0,0 +1,11 @@ +Types: deb deb-src +URIs: http://ftp.at.debian.org/debian +Suites: buster buster-updates +# comment in here +Components: main contrib +Languages: it + de + fr +Enabled: off +Languages-Add: ja +Languages-Remove: de diff --git a/tests/sources.list.d/options_comment.list b/tests/sources.list.d/options_comment.list new file mode 100644 index 0000000..6b73053 --- /dev/null +++ b/tests/sources.list.d/options_comment.list @@ -0,0 +1,3 @@ +deb [ lang=it,de arch=amd64 ] http://ftp.at.debian.org/debian buster main contrib # comment +deb [ lang=it,de arch=amd64 lang+=fr lang-=de ] http://ftp.at.debian.org/debian buster non-free # non-free :( + diff --git a/tests/sources.list.d/pbs-enterprise.list b/tests/sources.list.d/pbs-enterprise.list new file mode 100644 index 0000000..5f8763c --- /dev/null +++ b/tests/sources.list.d/pbs-enterprise.list @@ -0,0 +1 @@ +deb https://enterprise.proxmox.com/debian/pbs buster pbs-enterprise diff --git a/tests/sources.list.d/pve.list b/tests/sources.list.d/pve.list new file mode 100644 index 0000000..6213f72 --- /dev/null +++ b/tests/sources.list.d/pve.list @@ -0,0 +1,10 @@ +deb http://ftp.debian.org/debian buster main contrib +deb http://ftp.debian.org/debian buster-updates main contrib + +# PVE pve-no-subscription repository provided by proxmox.com, +# NOT recommended for production use +deb http://download.proxmox.com/debian/pve buster pve-no-subscription +# deb https://enterprise.proxmox.com/debian/pve buster pve-enterprise + +# security updates +deb http://security.debian.org/debian-security buster/updates main contrib diff --git a/tests/sources.list.d/standard.list b/tests/sources.list.d/standard.list new file mode 100644 index 0000000..26db887 --- /dev/null +++ b/tests/sources.list.d/standard.list @@ -0,0 +1,6 @@ +deb http://ftp.at.debian.org/debian buster main contrib + +deb http://ftp.at.debian.org/debian buster-updates main contrib + +# security updates +deb http://security.debian.org buster/updates main contrib diff --git a/tests/sources.list.d/standard.sources b/tests/sources.list.d/standard.sources new file mode 100644 index 0000000..605202e --- /dev/null +++ b/tests/sources.list.d/standard.sources @@ -0,0 +1,10 @@ +Types: deb +URIs: http://ftp.at.debian.org/debian +Suites: buster buster-updates +Components: main contrib + +# security updates +Types: deb +URIs: http://security.debian.org +Suites: buster/updates +Components: main contrib -- 2.20.1