diff --git a/src/build/binary.rs b/src/build/binary.rs index 46e2cdc..9830c3b 100644 --- a/src/build/binary.rs +++ b/src/build/binary.rs @@ -16,8 +16,6 @@ use std::sync::Arc; use crate::context::Context; use crate::debian::{ChecksumEntry, ControlInfo, FileChecksums, FilesList}; -use super::parse_checksum_field; - /// Digests of one artifact. #[derive(Debug, Clone, Default)] struct ArtifactHashes { @@ -57,7 +55,7 @@ pub struct BinaryMetadataOptions { /// `dpkg-genchanges -b`: sorted `Binary` list, encounter-order `Architecture` /// accumulation, sorted `Description` lines formatted like dpkg, `.buildinfo` /// registration in `debian/files`, and binary-NMU handling (`Source: -/// pkg (prev)` + previous `.dsc` redistribution when present). +/// pkg (prev)` + `Binary-Only-Changes`, with no source files distributed). pub fn generate_binary_metadata( ctx: &Arc, package_dir: &Path, @@ -137,28 +135,24 @@ pub fn generate_binary_metadata( } // ------------------------------------------------------------------ - // Binary-NMU: redistribute the previous source when present + // Binary-NMU: reference the previous source version, textually only // ------------------------------------------------------------------ let sversion = entry.version.no_epoch(); let mut source_display = entry.source.clone(); let mut binary_only_changes = None; if entry.binary_only { - // A binary-only upload must reference the previous source version; - // a changelog that cannot yield it is a hard error, like in the - // source-build path. Reuse the changelog read above instead of - // reading the file a second time. + // Like dpkg-genchanges/genbuildinfo, a binary-only upload references + // the previous source version in the `Source` field and records the + // entry in `Binary-Only-Changes`, but distributes NO source files: + // the previous `.dsc` and its tarballs already sit in the archive, + // and are not re-uploaded even when present next to the tree. if let Some(prev) = &previous_entry { source_display = format!("{} ({})", entry.source, prev.version.full()); binary_only_changes = Some(format!( "{}\n\n -- {} <{}> {}", entry.changes_field, entry.maintainer_name, entry.maintainer_email, entry.date_raw )); - let dsc_name = format!("{}_{}.dsc", entry.source, prev.version.no_epoch()); - let dsc_path = upload_dir.join(&dsc_name); - if ctx.exists(&dsc_path)? { - include_dsc_artifacts(ctx, upload_dir, &dsc_name, &mut checksums)?; - } } } @@ -297,6 +291,7 @@ pub fn generate_binary_metadata( date: entry.date_raw.clone(), source: source_display, binaries, + binary_only: entry.binary_only, built_for_profiles: opts.profiles.clone(), architecture: arch_values.join(" "), version: entry.version.full(), @@ -400,90 +395,6 @@ fn hashes_in_context( Ok(out) } -/// Pull the `.dsc` checksums (and its referenced tarballs) into the -/// checksum registry, mirroring how binary-NMU uploads redistribute the -/// previous source. -fn include_dsc_artifacts( - ctx: &Arc, - upload_dir: &Path, - dsc_name: &str, - checksums: &mut FileChecksums, -) -> Result<(), Box> { - let dsc_content = ctx.read_file(&upload_dir.join(dsc_name))?; - let para = crate::debian::control::parse_paragraphs( - crate::debian::control::strip_clearsigned_armour(&dsc_content), - ) - .into_iter() - .next() - .ok_or_else(|| format!("'{dsc_name}' is empty"))?; - - // Names and partial checksums are filled from the very same validated - // lines, so a listed name can never miss its checksum entry. - // Distribution order follows the Checksums fields (Checksums-Sha1 then - // Checksums-Sha256), like dpkg-genchanges; the `Files` field only - // supplements the md5 digests. - let mut names: Vec = Vec::new(); - let mut partials: BTreeMap = BTreeMap::new(); - for field in ["Checksums-Sha1", "Checksums-Sha256", "Files"] { - let Some(value) = para.get(field) else { - continue; - }; - for cl in parse_checksum_field(field, value) - .map_err(|e| format!("cannot parse '{dsc_name}': {e}"))? - { - let slot = partials.entry(cl.name.clone()).or_default(); - match field { - "Checksums-Sha1" => slot.sha1 = Some(cl.digest), - "Checksums-Sha256" => slot.sha256 = Some(cl.digest), - _ => slot.md5 = Some(cl.digest), - } - slot.size = Some(cl.size); - if field != "Files" && !names.contains(&cl.name) { - names.push(cl.name); - } - } - } - - // The .dsc itself is hashed fresh (it may be signed/rewritten); the - // tarballs reuse the .dsc-recorded digests, like dpkg-genchanges does. - let dsc_hashes = - hashes_in_context(ctx, upload_dir, std::slice::from_ref(&dsc_name.to_string()))?; - if let Some(h) = dsc_hashes.get(dsc_name) { - checksums.insert_entry( - dsc_name, - ChecksumEntry { - size: h.size, - md5: h.md5.clone(), - sha1: h.sha1.clone(), - sha256: h.sha256.clone(), - // No SHA-512 digest available (see above). - sha512: String::new(), - }, - ); - } - for name in &names { - if name == dsc_name { - continue; - } - let p = partials - .get(name) - .ok_or_else(|| format!("file '{name}' listed in '{dsc_name}' has no checksum entry"))?; - checksums.insert_entry( - name, - ChecksumEntry { - size: p.size.unwrap_or(0), - md5: p.md5.clone().unwrap_or_default(), - sha1: p.sha1.clone().unwrap_or_default(), - sha256: p.sha256.clone().unwrap_or_default(), - // The `.dsc` records no SHA-512 (dpkg only writes - // sha1/sha256 there). - sha512: String::new(), - }, - ); - } - Ok(()) -} - #[cfg(test)] mod tests { use super::*; @@ -521,109 +432,11 @@ mod tests { assert!(!environment.contains("DEBIAN_FRONTEND"), "{environment}"); } - /// A minimal previous-version `.dsc` with a 3-column Checksums-Sha1 - /// field, a 4-column Checksums-Sha256 line and a 3-column `Files`. - /// Regression: the old code filled `names` from any line with a third - /// column but `partials` only from exactly-3-column lines, so the - /// "bogus" name landed in `names` alone and `&partials["bogus"]` - /// panicked. It must produce a build error instead. - #[test] - fn dsc_four_column_checksum_line_errors_instead_of_panicking() { - let dsc_name = "hello_1.0-1.dsc"; - let dsc = "\ -Format: 3.0 (native) -Source: hello -Binary: hello -Architecture: any -Version: 1.0-1 -Maintainer: A B -Checksums-Sha1: - aaa111 12 hello_1.0.orig.tar.xz -Checksums-Sha256: - bbb222 12 bogus hello_1.0-1.debian.tar.xz -Files: - ddd333 12 hello_1.0.orig.tar.xz -"; - let base = tempfile::tempdir().expect("tempdir"); - std::fs::write(base.path().join(dsc_name), dsc).expect("write dsc"); - - let ctx = Arc::new( - crate::context::Context::new(crate::context::ContextConfig::Local).expect("context"), - ); - let mut checksums = FileChecksums::new(); - let err = include_dsc_artifacts(&ctx, base.path(), dsc_name, &mut checksums) - .expect_err("malformed Checksums-Sha256 line must fail the build"); - let err = err.to_string(); - assert!(err.contains("Checksums-Sha256"), "{err}"); - assert!(err.contains("hello_1.0-1.debian.tar.xz"), "{err}"); - } - - /// Happy path: tarball entries are assembled from the Checksums fields - /// (sha1/sha256) and merged with the legacy 5-column `Files` md5, in - /// Checksums-Sha1 order, with the `.dsc` itself hashed fresh first. - #[test] - fn include_dsc_artifacts_merges_legacy_files_layout() { - let dsc_name = "hello_1.0-1.dsc"; - let tarball = "hello_1.0.orig.tar.xz"; - let dsc = "\ -Format: 3.0 (quilt) -Source: hello -Binary: hello -Architecture: any -Version: 1.0-1 -Maintainer: A B -Checksums-Sha1: - aaa111 12 hello_1.0.orig.tar.xz -Checksums-Sha256: - bbb222 12 hello_1.0.orig.tar.xz -Files: - ddd333 12 devel optional hello_1.0.orig.tar.xz -"; - let base = tempfile::tempdir().expect("tempdir"); - std::fs::write(base.path().join(dsc_name), dsc).expect("write dsc"); - std::fs::write(base.path().join(tarball), "tarball bytes").expect("write tarball"); - - let ctx = Arc::new( - crate::context::Context::new(crate::context::ContextConfig::Local).expect("context"), - ); - let mut checksums = FileChecksums::new(); - include_dsc_artifacts(&ctx, base.path(), dsc_name, &mut checksums) - .expect("valid dsc must parse"); - - let collected: Vec<(String, crate::debian::ChecksumEntry)> = checksums - .iter() - .map(|(k, e)| (k.clone(), e.clone())) - .collect(); - assert_eq!( - collected - .iter() - .map(|(k, _)| k.as_str()) - .collect::>(), - vec![dsc_name, tarball], - ".dsc first, then Checksums-Sha1 order" - ); - - // The .dsc is hashed fresh from disk. - let dsc_entry = &collected[0].1; - assert_eq!(dsc_entry.size, dsc.len() as u64); - assert_eq!(dsc_entry.md5.len(), 32); - assert_eq!(dsc_entry.sha1.len(), 40); - assert_eq!(dsc_entry.sha256.len(), 64); - - // The tarball reuses the .dsc-recorded digests, including the - // legacy 5-column `Files` md5 (section/priority skipped). - let tar_entry = &collected[1].1; - assert_eq!(tar_entry.size, 12); - assert_eq!(tar_entry.md5, "ddd333"); - assert_eq!(tar_entry.sha1, "aaa111"); - assert_eq!(tar_entry.sha256, "bbb222"); - } - /// A binary-only (binNMU) build whose changelog cannot yield the /// previous entry (malformed second header, unbalanced parenthesis) must /// fail the metadata generation with a diagnostic naming the problem, /// instead of silently emitting a plain `Source:` `.changes` with no - /// `Binary-Only-Changes` and no redistributed previous `.dsc`. + /// `Binary-Only-Changes` and no previous-version reference. #[test] fn binary_only_prev_version_parse_failure_errors_instead_of_wrong_metadata() { let changelog = "\ @@ -739,4 +552,104 @@ Description: test package #[cfg(unix)] assert!(err.contains("Permission denied"), "{err}"); } + + /// A binary-only (binNMU) build references the previous source version + /// (`Source: pkg (prev)`, `Binary-Only-Changes`) but must NOT + /// redistribute any source file: like dpkg-genchanges/genbuildinfo, the + /// previous `.dsc` and its tarballs stay out of both documents even when + /// they exist next to the artifacts. + #[test] + fn binary_only_metadata_references_previous_source_without_redistributing_it() { + let changelog = "\ +hello (1.0-1+b1) unstable; urgency=medium, binary-only=yes + + * Binary-only rebuild. + + -- A B Mon, 01 Jan 2024 00:00:00 +0000 + +hello (1.0-1) unstable; urgency=medium + + * Initial release. + + -- A B Sun, 31 Dec 2023 00:00:00 +0000 +"; + let control = "\ +Source: hello +Section: devel +Priority: optional +Maintainer: A B + +Package: hello +Architecture: all +Description: test package +"; + let base = tempfile::tempdir().expect("tempdir"); + let tree = base.path().join("hello-1.0"); + std::fs::create_dir_all(tree.join("debian")).expect("mkdir tree"); + std::fs::write(tree.join("debian/changelog"), changelog).expect("write changelog"); + std::fs::write(tree.join("debian/control"), control).expect("write control"); + std::fs::write( + tree.join("debian/files"), + "hello_1.0-1+b1_all.deb devel optional\n", + ) + .expect("write files"); + std::fs::write(base.path().join("hello_1.0-1+b1_all.deb"), "deb payload") + .expect("write deb"); + + // The trap: the previous source artifacts sit right next to the + // binaries, as they would after a source build. dpkg does not + // redistribute them for a binary-only upload, and neither must we. + std::fs::write( + base.path().join("hello_1.0-1.dsc"), + "Format: 3.0 (quilt)\nSource: hello\nBinary: hello\nArchitecture: any\nVersion: \ + 1.0-1\nMaintainer: A B \nChecksums-Sha1:\n aaa111 12 \ + hello_1.0.orig.tar.xz\n", + ) + .expect("write previous dsc"); + std::fs::write(base.path().join("hello_1.0.orig.tar.xz"), "tarball bytes") + .expect("write previous tarball"); + + let ctx = Arc::new( + crate::context::Context::new(crate::context::ContextConfig::Local).expect("context"), + ); + let opts = BinaryMetadataOptions { + profiles: Vec::new(), + vendor: "debian".to_string(), + exported_env: BTreeMap::new(), + build_arch: "amd64".to_string(), + host_arch: "amd64".to_string(), + }; + let (buildinfo_path, changes_path) = + generate_binary_metadata(&ctx, &tree, base.path(), &opts) + .expect("binNMU metadata generation must succeed"); + + let changes = std::fs::read_to_string(&changes_path).expect("read changes"); + let buildinfo = std::fs::read_to_string(&buildinfo_path).expect("read buildinfo"); + + // The previous version is referenced textually. + assert!( + changes.contains("Source: hello (1.0-1)"), + "changes must reference the previous version: {changes}" + ); + assert!( + buildinfo.contains("Binary-Only-Changes"), + "buildinfo must record the binary-only entry: {buildinfo}" + ); + // ... but no source file is distributed, on either side. + for (doc, text) in [("changes", &changes), ("buildinfo", &buildinfo)] { + assert!( + !text.contains("hello_1.0-1.dsc"), + "{doc} must not redistribute the previous .dsc: {text}" + ); + assert!( + !text.contains("hello_1.0.orig.tar.xz"), + "{doc} must not redistribute the previous tarball: {text}" + ); + } + // The distributed set is exactly the binary artifacts + buildinfo. + assert!( + changes.contains("hello_1.0-1+b1_all.deb") && changes.contains(".buildinfo"), + "changes must distribute the deb and the buildinfo: {changes}" + ); + } } diff --git a/src/build/changes.rs b/src/build/changes.rs index 52e37c2..db37b77 100644 --- a/src/build/changes.rs +++ b/src/build/changes.rs @@ -65,6 +65,9 @@ pub struct ChangesInput { pub source: String, /// Sorted binary package names with artifacts (empty for source-only). pub binaries: Vec, + /// Whether the changelog entry is a binary-only (binNMU) upload + /// (`Binary-Only: yes` field). + pub binary_only: bool, /// Active build profiles (`Built-For-Profiles`); omitted when empty. pub built_for_profiles: Vec, /// `Architecture` field value in encounter order (e.g. `source`, @@ -142,6 +145,9 @@ pub fn render_changes(input: &ChangesInput) -> Paragraph { let joined = input.binaries.join(" "); p.set("Binary", &wrap_long(&joined)); } + if input.binary_only { + p.set("Binary-Only", "yes"); + } if !input.built_for_profiles.is_empty() { p.set("Built-For-Profiles", &input.built_for_profiles.join(" ")); } @@ -329,6 +335,7 @@ mod tests { date: "Sat, 22 Aug 2026 10:00:00 +0000".to_string(), source: "pkg".to_string(), binaries: vec![], + binary_only: false, built_for_profiles: vec![], architecture: "source".to_string(), version: "1.0".to_string(), diff --git a/src/build/mod.rs b/src/build/mod.rs index 8847f45..af33a4d 100644 --- a/src/build/mod.rs +++ b/src/build/mod.rs @@ -638,6 +638,7 @@ pub fn run_source_build( date: entry.date_raw.clone(), source: source_display.clone(), binaries: Vec::new(), // source-only upload + binary_only: entry.binary_only, built_for_profiles: profiles.clone(), architecture: "source".to_string(), version: entry.version.full(), @@ -1863,23 +1864,50 @@ Provides: virtual-thing (= 2.0), plain-virtual #[test] fn diff_binary_build_metadata() { const NAME: &str = "pkh-diff-m"; - let control = format!( - "Source: {NAME}\nSection: utils\nPriority: optional\nMaintainer: {MAINTAINER}\nBuild-Depends: libc6\n\n\ - Package: {NAME}\nArchitecture: any\nDescription: test package main\n long description\n\n\ - Package: {NAME}-u\nPackage-Type: udeb\nArchitecture: all\nDescription: test udeb\n short\n" - ); let changelog = format!( "{NAME} (1.0-1) unstable; urgency=medium\n\n * Binary build test.\n\n -- {MAINTAINER} {DATE}\n" ); - let rules = format!( - "#!/usr/bin/make -f\nV = $(shell dpkg-parsechangelog -S Version)\nA = $(shell dpkg-architecture -qDEB_HOST_ARCH)\n\nbuild:\n\tmkdir -p debian/tmp/usr/bin\n\tprintf '#!/bin/sh\\necho hi\\n' > debian/tmp/usr/bin/hello\n\tchmod 755 debian/tmp/usr/bin/hello\n\ttouch $@\n\nbinary: build\n\trm -rf debian/{NAME} debian/{NAME}-u\n\tmkdir -p debian/{NAME}/usr/bin debian/{NAME}/DEBIAN\n\tcp -r debian/tmp/. debian/{NAME}/\n\tdpkg-gencontrol -p{NAME} -Pdebian/{NAME}\n\tdpkg-deb --build debian/{NAME} ..\n\tmkdir -p debian/{NAME}-u/usr/share debian/{NAME}-u/DEBIAN\n\techo data > debian/{NAME}-u/usr/share/data.txt\n\tdpkg-gencontrol -p{NAME}-u -Pdebian/{NAME}-u\n\tdpkg-deb --build debian/{NAME}-u ..\n\tmv ../{NAME}-u_$(V)_all.deb ../{NAME}-u_$(V)_all.udeb\n\nclean:\n\trm -rf debian/tmp debian/{NAME} debian/{NAME}-u build-stamp debian/files debian/*.substvars\n\n.PHONY: build binary clean\n" - ); + diff_binary_metadata_case(NAME, &changelog, "1.0-1", false); + } + + /// debian/control shared by the binary-metadata differential cases: one + /// arch:any deb and one arch:all udeb. + fn binary_test_control(name: &str) -> String { + format!( + "Source: {name}\nSection: utils\nPriority: optional\nMaintainer: {MAINTAINER}\nBuild-Depends: libc6\n\n\ + Package: {name}\nArchitecture: any\nDescription: test package main\n long description\n\n\ + Package: {name}-u\nPackage-Type: udeb\nArchitecture: all\nDescription: test udeb\n short\n" + ) + } + + /// debian/rules driving dpkg-gencontrol/dpkg-deb directly (no debhelper). + fn binary_test_rules(name: &str) -> String { + format!( + "#!/usr/bin/make -f\nV = $(shell dpkg-parsechangelog -S Version)\nA = $(shell dpkg-architecture -qDEB_HOST_ARCH)\n\nbuild:\n\tmkdir -p debian/tmp/usr/bin\n\tprintf '#!/bin/sh\\necho hi\\n' > debian/tmp/usr/bin/hello\n\tchmod 755 debian/tmp/usr/bin/hello\n\ttouch $@\n\nbinary: build\n\trm -rf debian/{name} debian/{name}-u\n\tmkdir -p debian/{name}/usr/bin debian/{name}/DEBIAN\n\tcp -r debian/tmp/. debian/{name}/\n\tdpkg-gencontrol -p{name} -Pdebian/{name}\n\tdpkg-deb --build debian/{name} ..\n\tmkdir -p debian/{name}-u/usr/share debian/{name}-u/DEBIAN\n\techo data > debian/{name}-u/usr/share/data.txt\n\tdpkg-gencontrol -p{name}-u -Pdebian/{name}-u\n\tdpkg-deb --build debian/{name}-u ..\n\tmv ../{name}-u_$(V)_all.deb ../{name}-u_$(V)_all.udeb\n\nclean:\n\trm -rf debian/tmp debian/{name} debian/{name}-u build-stamp debian/files debian/*.substvars\n\n.PHONY: build binary clean\n" + ) + } + + /// Both-sides binary metadata comparison for one changelog: golden + /// `dpkg-buildpackage -b` against the pkh deb flow (rules targets with a + /// dpkg-buildpackage-like environment) plus native metadata generation. + /// `artifact_version` is the full version the artifacts are named after; + /// `with_prev_source` additionally places the previous version's `.dsc` + /// and tarball next to the tree on both sides (the binNMU trap: they must + /// not be redistributed). + fn diff_binary_metadata_case( + name: &str, + changelog: &str, + artifact_version: &str, + with_prev_source: bool, + ) { + let control = binary_test_control(name); + let rules = binary_test_rules(name); let write_tree = |root: &Path| { - fs::create_dir_all(root.join(format!("{NAME}/debian/source"))).expect("mkdir tree"); - let tree = root.join(NAME); + fs::create_dir_all(root.join(format!("{name}/debian/source"))).expect("mkdir tree"); + let tree = root.join(name); fs::write(tree.join("debian/control"), &control).expect("write control"); - fs::write(tree.join("debian/changelog"), &changelog).expect("write changelog"); + fs::write(tree.join("debian/changelog"), changelog).expect("write changelog"); fs::write(tree.join("debian/source/format"), "3.0 (native)\n").expect("write format"); fs::write(tree.join("debian/rules"), &rules).expect("write rules"); #[cfg(unix)] @@ -1900,6 +1928,20 @@ Provides: virtual-thing (= 2.0), plain-virtual let golden_tree = write_tree(&golden_root); let ours_tree = write_tree(&ours_root); + if with_prev_source { + for root in [&golden_root, &ours_root] { + let dsc = format!( + "Format: 3.0 (native)\nSource: {name}\nBinary: {name}\nArchitecture: all\n\ + Version: 1.0-1\nMaintainer: {MAINTAINER}\nChecksums-Sha1:\n aaa111 12 \ + {name}_1.0.tar.xz\nChecksums-Sha256:\n bbb222 12 {name}_1.0.tar.xz\nFiles:\n \ + ddd333 12 utils optional {name}_1.0.tar.xz\n" + ); + fs::write(root.join(format!("{name}_1.0-1.dsc")), dsc).expect("write previous dsc"); + fs::write(root.join(format!("{name}_1.0.tar.xz")), "tarball byte") + .expect("write previous tarball"); + } + } + // Golden side: real dpkg-buildpackage binary build. let status = crate::test_support::run_logged( Command::new("dpkg-buildpackage") @@ -1915,7 +1957,7 @@ Provides: virtual-thing (= 2.0), plain-virtual // rules targets directly by default (missing Rules-Requires-Root is // treated as 'no'), so no fakeroot wrapper here either. let entry = - crate::debian::parse_changelog_entry_from_str(&changelog).expect("parse changelog"); + crate::debian::parse_changelog_entry_from_str(changelog).expect("parse changelog"); let vendor = env::current_vendor(); let profiles = env::resolve_build_profiles(&[], &vendor); let parallel = env::num_parallel(); @@ -1959,15 +2001,32 @@ Provides: virtual-thing (= 2.0), plain-virtual // Compare artifacts. assert_changes_equivalent( - &golden_root.join(format!("{NAME}_1.0-1_amd64.changes")), - &ours_root.join(format!("{NAME}_1.0-1_amd64.changes")), + &golden_root.join(format!("{name}_{artifact_version}_amd64.changes")), + &ours_root.join(format!("{name}_{artifact_version}_amd64.changes")), ); assert_buildinfo_equivalent( - &golden_root.join(format!("{NAME}_1.0-1_amd64.buildinfo")), - &ours_root.join(format!("{NAME}_1.0-1_amd64.buildinfo")), + &golden_root.join(format!("{name}_{artifact_version}_amd64.buildinfo")), + &ours_root.join(format!("{name}_{artifact_version}_amd64.buildinfo")), ); } + /// Differential check of the binary-only (binNMU) metadata against real + /// `dpkg-buildpackage -b`: with the previous source artifacts sitting + /// next to the tree (as after a source build), the `.changes` must + /// distribute only the binaries and the `.buildinfo`, both documents + /// referencing the previous version textually only. Regression guard for + /// the previous-source redistribution pkh used to emit. + #[test] + fn diff_binmu_binary_metadata() { + const NAME: &str = "pkh-diff-r"; + let changelog = format!( + "{NAME} (1.0-1+b1) unstable; urgency=medium, binary-only=yes\n\n * Binary-only \ + rebuild.\n\n -- {MAINTAINER} {DATE}\n\n{NAME} (1.0-1) unstable; urgency=medium\n\n \ + * Initial release.\n\n -- {MAINTAINER} {DATE}\n" + ); + diff_binary_metadata_case(NAME, &changelog, "1.0-1+b1", true); + } + #[test] fn diff_native_minimal() { differential_case(&FixtureSpec::new("pkh-diff-a", "1.0-1", "unstable"));