| 748 |
748 |
|
}
|
| 749 |
749 |
|
}
|
| 750 |
750 |
|
|
|
751 |
+ |
/// A recording, programmable [`Executor`] for the macOS sign chain. Every
|
|
752 |
+ |
/// dispatched shell command is captured for assertion, and its exit code +
|
|
753 |
+ |
/// stdout is chosen by the first rule whose needle the command contains — so
|
|
754 |
+ |
/// a test can make `notarytool` report `Accepted`, make `codesign` fail, or
|
|
755 |
+ |
/// make `spctl` emit the Gatekeeper sentinel without a real Mac or SSH. A
|
|
756 |
+ |
/// rule may carry a *sequence* of responses (one per successive match) to
|
|
757 |
+ |
/// drive the notarize retry loop; the last entry repeats once the sequence
|
|
758 |
+ |
/// is exhausted. Unmatched commands succeed as empty no-ops (so a plain
|
|
759 |
+ |
/// build `sh_ok` passes), and the sync/preflight ops are no-ops.
|
|
760 |
+ |
struct ScriptedExec {
|
|
761 |
+ |
caps: CapabilitySet,
|
|
762 |
+ |
rules: Vec<ScriptRule>,
|
|
763 |
+ |
log: Arc<std::sync::Mutex<Vec<String>>>,
|
|
764 |
+ |
}
|
|
765 |
+ |
|
|
766 |
+ |
struct ScriptRule {
|
|
767 |
+ |
needle: String,
|
|
768 |
+ |
responses: Vec<(i32, String)>,
|
|
769 |
+ |
calls: std::sync::atomic::AtomicUsize,
|
|
770 |
+ |
}
|
|
771 |
+ |
|
|
772 |
+ |
impl ScriptedExec {
|
|
773 |
+ |
fn new() -> Self {
|
|
774 |
+ |
Self {
|
|
775 |
+ |
// A mac host's real grant. Nothing in the dispatch path gates on
|
|
776 |
+ |
// it (this fake never calls `gate`), but keep it coherent so
|
|
777 |
+ |
// `capabilities()` is not a lie.
|
|
778 |
+ |
caps: CapabilitySet::from_tokens(
|
|
779 |
+ |
["build", "sign", "notarize", "staple"],
|
|
780 |
+ |
["build-log", "artifact"],
|
|
781 |
+ |
),
|
|
782 |
+ |
rules: Vec::new(),
|
|
783 |
+ |
log: Arc::new(std::sync::Mutex::new(Vec::new())),
|
|
784 |
+ |
}
|
|
785 |
+ |
}
|
|
786 |
+ |
|
|
787 |
+ |
/// Respond to every command containing `needle` with `(code, stdout)`.
|
|
788 |
+ |
fn on(mut self, needle: &str, code: i32, stdout: &str) -> Self {
|
|
789 |
+ |
self.rules.push(ScriptRule {
|
|
790 |
+ |
needle: needle.to_string(),
|
|
791 |
+ |
responses: vec![(code, stdout.to_string())],
|
|
792 |
+ |
calls: std::sync::atomic::AtomicUsize::new(0),
|
|
793 |
+ |
});
|
|
794 |
+ |
self
|
|
795 |
+ |
}
|
|
796 |
+ |
|
|
797 |
+ |
/// Respond to successive `needle` matches with successive responses; the
|
|
798 |
+ |
/// last repeats once the list is exhausted. Drives the notarize retry.
|
|
799 |
+ |
fn on_seq(mut self, needle: &str, responses: &[(i32, &str)]) -> Self {
|
|
800 |
+ |
self.rules.push(ScriptRule {
|
|
801 |
+ |
needle: needle.to_string(),
|
|
802 |
+ |
responses: responses
|
|
803 |
+ |
.iter()
|
|
804 |
+ |
.map(|(c, s)| (*c, (*s).to_string()))
|
|
805 |
+ |
.collect(),
|
|
806 |
+ |
calls: std::sync::atomic::AtomicUsize::new(0),
|
|
807 |
+ |
});
|
|
808 |
+ |
self
|
|
809 |
+ |
}
|
|
810 |
+ |
|
|
811 |
+ |
/// Every shell command this executor was asked to run, in order.
|
|
812 |
+ |
fn commands(&self) -> Vec<String> {
|
|
813 |
+ |
self.log.lock().unwrap().clone()
|
|
814 |
+ |
}
|
|
815 |
+ |
}
|
|
816 |
+ |
|
|
817 |
+ |
#[async_trait]
|
|
818 |
+ |
impl Executor for ScriptedExec {
|
|
819 |
+ |
async fn run_streaming(
|
|
820 |
+ |
&self,
|
|
821 |
+ |
step: &ops_exec::Step,
|
|
822 |
+ |
_sink: &mut dyn LogSink,
|
|
823 |
+ |
) -> anyhow::Result<RunOutput> {
|
|
824 |
+ |
let cmd = step.argv.last().cloned().unwrap_or_default();
|
|
825 |
+ |
self.log.lock().unwrap().push(cmd.clone());
|
|
826 |
+ |
let (code, stdout) = self.rules.iter().find(|r| cmd.contains(&r.needle)).map_or(
|
|
827 |
+ |
(0, String::new()),
|
|
828 |
+ |
|r| {
|
|
829 |
+ |
let i = r.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
|
830 |
+ |
r.responses[i.min(r.responses.len() - 1)].clone()
|
|
831 |
+ |
},
|
|
832 |
+ |
);
|
|
833 |
+ |
Ok(RunOutput {
|
|
834 |
+ |
// Shift into the wait-status word's exit-code byte so
|
|
835 |
+ |
// `ExitStatus::code()` reports `code` exactly (a bare
|
|
836 |
+ |
// `from_raw(1)` reads as a signal, yielding `None`).
|
|
837 |
+ |
status: std::process::ExitStatus::from_raw(code << 8),
|
|
838 |
+ |
stdout: stdout.into_bytes(),
|
|
839 |
+ |
stderr: Vec::new(),
|
|
840 |
+ |
})
|
|
841 |
+ |
}
|
|
842 |
+ |
async fn pull_file(
|
|
843 |
+ |
&self,
|
|
844 |
+ |
_r: &std::path::Path,
|
|
845 |
+ |
_l: &std::path::Path,
|
|
846 |
+ |
_o: &SyncOpts,
|
|
847 |
+ |
) -> anyhow::Result<()> {
|
|
848 |
+ |
Ok(())
|
|
849 |
+ |
}
|
|
850 |
+ |
async fn pull_dir(
|
|
851 |
+ |
&self,
|
|
852 |
+ |
_r: &std::path::Path,
|
|
853 |
+ |
_l: &std::path::Path,
|
|
854 |
+ |
_o: &SyncOpts,
|
|
855 |
+ |
) -> anyhow::Result<()> {
|
|
856 |
+ |
Ok(())
|
|
857 |
+ |
}
|
|
858 |
+ |
async fn pull_glob(
|
|
859 |
+ |
&self,
|
|
860 |
+ |
_g: &str,
|
|
861 |
+ |
_l: &std::path::Path,
|
|
862 |
+ |
_o: &SyncOpts,
|
|
863 |
+ |
) -> anyhow::Result<()> {
|
|
864 |
+ |
Ok(())
|
|
865 |
+ |
}
|
|
866 |
+ |
async fn push_dir(
|
|
867 |
+ |
&self,
|
|
868 |
+ |
_l: &std::path::Path,
|
|
869 |
+ |
_r: &std::path::Path,
|
|
870 |
+ |
_o: &SyncOpts,
|
|
871 |
+ |
) -> anyhow::Result<()> {
|
|
872 |
+ |
Ok(())
|
|
873 |
+ |
}
|
|
874 |
+ |
async fn preflight(&self) -> anyhow::Result<()> {
|
|
875 |
+ |
Ok(())
|
|
876 |
+ |
}
|
|
877 |
+ |
fn capabilities(&self) -> &CapabilitySet {
|
|
878 |
+ |
&self.caps
|
|
879 |
+ |
}
|
|
880 |
+ |
}
|
|
881 |
+ |
|
| 751 |
882 |
|
/// Assemble an [`AppState`] from the three per-test inputs, filling in the
|
| 752 |
883 |
|
/// executors/syncs (built from `topo`) and the fixed test scaffolding
|
| 753 |
884 |
|
/// (metrics handle, event bus, standard OTA registry, empty active map, no
|
| 1939 |
2070 |
|
);
|
| 1940 |
2071 |
|
assert!(!marker.exists(), "denied build step must NOT have executed");
|
| 1941 |
2072 |
|
}
|
|
2073 |
+ |
|
|
2074 |
+ |
// ---- macOS sign / notarize / staple execution chain (via ScriptedExec) ----
|
|
2075 |
+ |
|
|
2076 |
+ |
/// Stand up a single-macOS-target app whose recipe is `recipe_body` (with the
|
|
2077 |
+ |
/// literal `ARTIFACT` replaced by a real, non-empty file on disk), dispatch
|
|
2078 |
+ |
/// every host command through `scripted`, run the build to a terminal state,
|
|
2079 |
+ |
/// and return the pool plus the final `(status, error)`. The returned
|
|
2080 |
+ |
/// [`tempfile::TempDir`] must be kept alive by the caller: it holds the
|
|
2081 |
+ |
/// sqlite DB the pool reads.
|
|
2082 |
+ |
async fn run_macos_recipe(
|
|
2083 |
+ |
scripted: Arc<ScriptedExec>,
|
|
2084 |
+ |
recipe_body: &str,
|
|
2085 |
+ |
backoff_secs: Option<u64>,
|
|
2086 |
+ |
) -> (tempfile::TempDir, SqlitePool, String, String) {
|
|
2087 |
+ |
let tmp = tempfile::tempdir().unwrap();
|
|
2088 |
+ |
let root = tmp.path();
|
|
2089 |
+ |
let repo = root.join("app");
|
|
2090 |
+ |
std::fs::create_dir_all(repo.join("src-tauri")).unwrap();
|
|
2091 |
+ |
std::fs::write(
|
|
2092 |
+ |
repo.join("src-tauri/tauri.conf.json"),
|
|
2093 |
+ |
r#"{"version":"0.0.1"}"#,
|
|
2094 |
+ |
)
|
|
2095 |
+ |
.unwrap();
|
|
2096 |
+ |
std::fs::create_dir_all(repo.join("dist/recipes")).unwrap();
|
|
2097 |
+ |
|
|
2098 |
+ |
// A real, non-empty artifact so `publish`'s size floor is satisfied; the
|
|
2099 |
+ |
// build step is faked, so nothing else creates it.
|
|
2100 |
+ |
let artifact = repo.join("out/demo.dmg");
|
|
2101 |
+ |
std::fs::create_dir_all(artifact.parent().unwrap()).unwrap();
|
|
2102 |
+ |
std::fs::write(&artifact, b"dmg-bytes").unwrap();
|
|
2103 |
+ |
|
|
2104 |
+ |
std::fs::write(
|
|
2105 |
+ |
repo.join("dist/recipes/macos.rhai"),
|
|
2106 |
+ |
recipe_body.replace("ARTIFACT", artifact.to_str().unwrap()),
|
|
2107 |
+ |
)
|
|
2108 |
+ |
.unwrap();
|
|
2109 |
+ |
|
|
2110 |
+ |
let cfg = Config {
|
|
2111 |
+ |
notarize_backoff_secs: backoff_secs,
|
|
2112 |
+ |
..Config::for_tests(root)
|
|
2113 |
+ |
};
|
|
2114 |
+ |
let pool = crate::db::open(&cfg.db_path).await.unwrap();
|
|
2115 |
+ |
std::fs::write(repo.join("bento.toml"), "targets = [\"macos/aarch64\"]\n").unwrap();
|
|
2116 |
+ |
let topo = Topology::from_str_for_tests(&format!(
|
|
2117 |
+ |
r#"
|
|
2118 |
+ |
[[host]]
|
|
2119 |
+ |
name = "mbp"
|
|
2120 |
+ |
ssh = "local"
|
|
2121 |
+ |
targets = ["macos/aarch64"]
|
|
2122 |
+ |
|
|
2123 |
+ |
[app.demo]
|
|
2124 |
+ |
repo = "{}"
|
|
2125 |
+ |
"#,
|
|
2126 |
+ |
repo.display()
|
|
2127 |
+ |
))
|
|
2128 |
+ |
.unwrap();
|
|
2129 |
+ |
|
|
2130 |
+ |
let mut state = test_state(pool.clone(), topo, cfg);
|
|
2131 |
+ |
// Route every host command through the scripted executor.
|
|
2132 |
+ |
let mut execs = HashMap::new();
|
|
2133 |
+ |
execs.insert("mbp".to_string(), scripted as Arc<dyn Executor>);
|
|
2134 |
+ |
state.executors = Arc::new(execs);
|
|
2135 |
+ |
|
|
2136 |
+ |
let build_id = start_build(
|
|
2137 |
+ |
state.clone(),
|
|
2138 |
+ |
AppId::new("demo"),
|
|
2139 |
+ |
Version::parse("0.0.1").unwrap(),
|
|
2140 |
+ |
vec!["macos/aarch64".parse().unwrap()],
|
|
2141 |
+ |
)
|
|
2142 |
+ |
.await
|
|
2143 |
+ |
.unwrap();
|
|
2144 |
+ |
|
|
2145 |
+ |
let mut status = String::new();
|
|
2146 |
+ |
let mut error = String::new();
|
|
2147 |
+ |
for _ in 0..100 {
|
|
2148 |
+ |
let row: Option<(String, Option<String>)> =
|
|
2149 |
+ |
sqlx::query_as("SELECT status, error FROM target_runs WHERE build_id = ?")
|
|
2150 |
+ |
.bind(build_id)
|
|
2151 |
+ |
.fetch_optional(&pool)
|
|
2152 |
+ |
.await
|
|
2153 |
+ |
.unwrap();
|
|
2154 |
+ |
if let Some((s, e)) = row {
|
|
2155 |
+ |
status = s;
|
|
2156 |
+ |
error = e.unwrap_or_default();
|
|
2157 |
+ |
if status != "running" {
|
|
2158 |
+ |
break;
|
|
2159 |
+ |
}
|
|
2160 |
+ |
}
|
|
2161 |
+ |
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
|
2162 |
+ |
}
|
|
2163 |
+ |
(tmp, pool, status, error)
|
|
2164 |
+ |
}
|
|
2165 |
+ |
|
|
2166 |
+ |
async fn release_count(pool: &SqlitePool) -> i64 {
|
|
2167 |
+ |
sqlx::query_scalar("SELECT COUNT(*) FROM releases")
|
|
2168 |
+ |
.fetch_one(pool)
|
|
2169 |
+ |
.await
|
|
2170 |
+ |
.unwrap()
|
|
2171 |
+ |
}
|
|
2172 |
+ |
|
|
2173 |
+ |
/// The whole macOS release chain end to end through a fake host: codesign,
|
|
2174 |
+ |
/// notarize (Accepted first try), staple, verify_gatekeeper, then publish.
|
|
2175 |
+ |
/// The recorded commands lock the exact incantations each host function
|
|
2176 |
+ |
/// dispatches, and a `releases` row proves the publish gate opened for a
|
|
2177 |
+ |
/// signed + notarized + Gatekeeper-accepted artifact.
|
|
2178 |
+ |
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
2179 |
+ |
async fn a_macos_recipe_signs_notarizes_staples_verifies_and_publishes() {
|
|
2180 |
+ |
let scripted = Arc::new(
|
|
2181 |
+ |
ScriptedExec::new()
|
|
2182 |
+ |
.on("codesign", 0, "")
|
|
2183 |
+ |
.on("notarytool", 0, r#"{"status":"Accepted"}"#)
|
|
2184 |
+ |
.on("stapler staple", 0, "")
|
|
2185 |
+ |
.on(
|
|
2186 |
+ |
"spctl",
|
|
2187 |
+ |
0,
|
|
2188 |
+ |
"source=Notarized Developer ID\nBENTO_GATEKEEPER_OK",
|
|
2189 |
+ |
),
|
|
2190 |
+ |
);
|
|
2191 |
+ |
let recipe = r#"
|
|
2192 |
+ |
let h = build_host();
|
|
2193 |
+ |
step("build");
|
|
2194 |
+ |
sh_ok(h, "echo built");
|
|
2195 |
+ |
step("sign");
|
|
2196 |
+ |
codesign(h, "Developer ID Application: Test", "ARTIFACT");
|
|
2197 |
+ |
notarize(h, "ARTIFACT");
|
|
2198 |
+ |
staple(h, "ARTIFACT");
|
|
2199 |
+ |
step("verify");
|
|
2200 |
+ |
if !verify_gatekeeper(h, "ARTIFACT") { throw "not Gatekeeper-accepted"; }
|
|
2201 |
+ |
step("publish");
|
|
2202 |
+ |
publish("tauri-mnw", "demo", "macos/aarch64", "0.0.1", "ARTIFACT", #{});
|
|
2203 |
+ |
"#;
|
|
2204 |
+ |
let (_tmp, pool, status, error) = run_macos_recipe(scripted.clone(), recipe, None).await;
|
|
2205 |
+ |
|
|
2206 |
+ |
assert_eq!(
|
|
2207 |
+ |
status, "ok",
|
|
2208 |
+ |
"signed+notarized macOS build should publish: {error}"
|
|
2209 |
+ |
);
|
|
2210 |
+ |
assert_eq!(
|
|
2211 |
+ |
release_count(&pool).await,
|
|
2212 |
+ |
1,
|
|
2213 |
+ |
"publish must record a release"
|
|
2214 |
+ |
);
|
|
2215 |
+ |
|
|
2216 |
+ |
// The exact shell incantations each host function dispatched.
|
|
2217 |
+ |
let cmds = scripted.commands();
|
|
2218 |
+ |
let has = |needle: &str| cmds.iter().any(|c| c.contains(needle));
|
|
2219 |
+ |
assert!(
|
|
2220 |
+ |
has("codesign --force --options runtime --timestamp --sign"),
|
|
2221 |
+ |
"codesign runtime+timestamp incantation, got: {cmds:?}"
|
|
2222 |
+ |
);
|
|
2223 |
+ |
assert!(
|
|
2224 |
+ |
has("xcrun notarytool submit"),
|
|
2225 |
+ |
"notarytool submit: {cmds:?}"
|
|
2226 |
+ |
);
|
|
2227 |
+ |
assert!(
|
|
2228 |
+ |
has("--wait --output-format json"),
|
|
2229 |
+ |
"notarytool --wait json: {cmds:?}"
|
|
2230 |
+ |
);
|
|
2231 |
+ |
assert!(has("xcrun stapler staple"), "stapler staple: {cmds:?}");
|
|
2232 |
+ |
assert!(has("spctl --assess"), "gatekeeper assess: {cmds:?}");
|
|
2233 |
+ |
}
|
|
2234 |
+ |
|
|
2235 |
+ |
/// A failing `codesign` fails the sign step and aborts the recipe before
|
|
2236 |
+ |
/// publish — an unsigned artifact never ships.
|
|
2237 |
+ |
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
2238 |
+ |
async fn a_codesign_failure_fails_the_sign_step_and_blocks_publish() {
|
|
2239 |
+ |
let scripted = Arc::new(ScriptedExec::new().on("codesign", 1, ""));
|
|
2240 |
+ |
let recipe = r#"
|
|
2241 |
+ |
let h = build_host();
|
|
2242 |
+ |
step("build");
|
|
2243 |
+ |
sh_ok(h, "echo built");
|
|
2244 |
+ |
step("sign");
|
|
2245 |
+ |
codesign(h, "Developer ID Application: Test", "ARTIFACT");
|
|
2246 |
+ |
step("publish");
|
|
2247 |
+ |
publish("tauri-mnw", "demo", "macos/aarch64", "0.0.1", "ARTIFACT", #{});
|
|
2248 |
+ |
"#;
|
|
2249 |
+ |
let (_tmp, pool, status, error) = run_macos_recipe(scripted, recipe, None).await;
|
|
2250 |
+ |
|
|
2251 |
+ |
assert_eq!(status, "failed", "a failed codesign must fail the target");
|
|
2252 |
+ |
assert!(
|
|
2253 |
+ |
error.contains("codesign failed"),
|
|
2254 |
+ |
"error names the codesign failure, got: {error}"
|
|
2255 |
+ |
);
|
|
2256 |
+ |
assert_eq!(
|
|
2257 |
+ |
release_count(&pool).await,
|
|
2258 |
+ |
0,
|
|
2259 |
+ |
"nothing may publish after a codesign failure"
|
|
2260 |
+ |
);
|
|
2261 |
+ |
}
|
|
2262 |
+ |
|
|
2263 |
+ |
/// Even if the recipe ignores `verify_gatekeeper`'s returned `false`, the
|
|
2264 |
+ |
/// publish gate refuses the artifact: `verify_gatekeeper` both records the
|
|
2265 |
+ |
/// rejection and fails its step, and `publish` proves neither passed. This
|
|
2266 |
+ |
/// is the defense-in-depth the pure `PublishAuthority::prove` tests assert in
|
|
2267 |
+ |
/// isolation, here exercised through the real host-function path.
|
|
2268 |
+ |
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
2269 |
+ |
async fn a_gatekeeper_rejection_bars_publish_even_if_the_recipe_ignores_it() {
|
|
2270 |
+ |
let scripted = Arc::new(
|
|
2271 |
+ |
ScriptedExec::new()
|
|
2272 |
+ |
.on("codesign", 0, "")
|
|
2273 |
+ |
.on("notarytool", 0, r#"{"status":"Accepted"}"#)
|
|
2274 |
+ |
.on("stapler staple", 0, "")
|
|
2275 |
+ |
// Gatekeeper says no: the sentinel is FAIL, not OK.
|
|
2276 |
+ |
.on("spctl", 0, "source=Unnotarized\nBENTO_GATEKEEPER_FAIL"),
|
|
2277 |
+ |
);
|
|
2278 |
+ |
let recipe = r#"
|
|
2279 |
+ |
let h = build_host();
|
|
2280 |
+ |
step("build");
|
|
2281 |
+ |
sh_ok(h, "echo built");
|
|
2282 |
+ |
step("sign");
|
|
2283 |
+ |
codesign(h, "Developer ID Application: Test", "ARTIFACT");
|
|
2284 |
+ |
notarize(h, "ARTIFACT");
|
|
2285 |
+ |
staple(h, "ARTIFACT");
|
|
2286 |
+ |
step("verify");
|
|
2287 |
+ |
verify_gatekeeper(h, "ARTIFACT");
|
|
2288 |
+ |
step("publish");
|
|
2289 |
+ |
publish("tauri-mnw", "demo", "macos/aarch64", "0.0.1", "ARTIFACT", #{});
|
|
2290 |
+ |
"#;
|
|
2291 |
+ |
let (_tmp, pool, status, error) = run_macos_recipe(scripted, recipe, None).await;
|
|
2292 |
+ |
|
|
2293 |
+ |
assert_eq!(
|
|
2294 |
+ |
status, "failed",
|
|
2295 |
+ |
"a Gatekeeper-rejected artifact must not publish"
|
|
2296 |
+ |
);
|
|
2297 |
+ |
assert!(
|
|
2298 |
+ |
!error.is_empty(),
|
|
2299 |
+ |
"the barred publish must surface an error"
|
|
2300 |
+ |
);
|
|
2301 |
+ |
assert_eq!(
|
|
2302 |
+ |
release_count(&pool).await,
|
|
2303 |
+ |
0,
|
|
2304 |
+ |
"no release for a rejected artifact"
|
|
2305 |
+ |
);
|
|
2306 |
+ |
}
|
|
2307 |
+ |
|
|
2308 |
+ |
/// The one flaky, network-bound step: `notarize` retries a non-`Accepted`
|
|
2309 |
+ |
/// result and succeeds on a later attempt. Two notarytool calls (reject then
|
|
2310 |
+ |
/// accept) then a recorded release prove the retry ran and the chain
|
|
2311 |
+ |
/// completed. Backoff is 0 so the retry sleep doesn't stall the test.
|
|
2312 |
+ |
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
2313 |
+ |
async fn notarize_retries_a_non_accepted_result_then_succeeds() {
|
|
2314 |
+ |
let scripted = Arc::new(
|
|
2315 |
+ |
ScriptedExec::new()
|
|
2316 |
+ |
.on("codesign", 0, "")
|
|
2317 |
+ |
.on_seq(
|
|
2318 |
+ |
"notarytool",
|
|
2319 |
+ |
&[
|
|
2320 |
+ |
(0, r#"{"status":"In Progress"}"#),
|
|
2321 |
+ |
(0, r#"{"status":"Accepted"}"#),
|
|
2322 |
+ |
],
|
|
2323 |
+ |
)
|
|
2324 |
+ |
.on("stapler staple", 0, "")
|
|
2325 |
+ |
.on(
|
|
2326 |
+ |
"spctl",
|
|
2327 |
+ |
0,
|
|
2328 |
+ |
"source=Notarized Developer ID\nBENTO_GATEKEEPER_OK",
|
|
2329 |
+ |
),
|
|
2330 |
+ |
);
|
|
2331 |
+ |
let recipe = r#"
|
|
2332 |
+ |
let h = build_host();
|
|
2333 |
+ |
step("build");
|
|
2334 |
+ |
sh_ok(h, "echo built");
|
|
2335 |
+ |
step("sign");
|
|
2336 |
+ |
codesign(h, "Developer ID Application: Test", "ARTIFACT");
|
|
2337 |
+ |
notarize(h, "ARTIFACT");
|
|
2338 |
+ |
staple(h, "ARTIFACT");
|
|
2339 |
+ |
step("verify");
|
|
2340 |
+ |
if !verify_gatekeeper(h, "ARTIFACT") { throw "not Gatekeeper-accepted"; }
|
|
2341 |
+ |
step("publish");
|
|
2342 |
+ |
publish("tauri-mnw", "demo", "macos/aarch64", "0.0.1", "ARTIFACT", #{});
|
|
2343 |
+ |
"#;
|
|
2344 |
+ |
let (_tmp, pool, status, error) = run_macos_recipe(scripted.clone(), recipe, Some(0)).await;
|
|
2345 |
+ |
|
|
2346 |
+ |
assert_eq!(
|
|
2347 |
+ |
status, "ok",
|
|
2348 |
+ |
"notarize should succeed on the retry: {error}"
|
|
2349 |
+ |
);
|
|
2350 |
+ |
assert_eq!(
|
|
2351 |
+ |
release_count(&pool).await,
|
|
2352 |
+ |
1,
|
|
2353 |
+ |
"the retried build still publishes"
|
|
2354 |
+ |
);
|
|
2355 |
+ |
let notary_calls = scripted
|
|
2356 |
+ |
.commands()
|
|
2357 |
+ |
.iter()
|
|
2358 |
+ |
.filter(|c| c.contains("notarytool"))
|
|
2359 |
+ |
.count();
|
|
2360 |
+ |
assert_eq!(
|
|
2361 |
+ |
notary_calls, 2,
|
|
2362 |
+ |
"notarytool ran once, was rejected, then ran again"
|
|
2363 |
+ |
);
|
|
2364 |
+ |
}
|
|
2365 |
+ |
|
|
2366 |
+ |
/// `notarize` gives up after its bounded retries: three notarytool attempts,
|
|
2367 |
+ |
/// all non-`Accepted`, fail the target and bar publish.
|
|
2368 |
+ |
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
2369 |
+ |
async fn notarize_fails_the_target_after_exhausting_its_retries() {
|
|
2370 |
+ |
let scripted = Arc::new(
|
|
2371 |
+ |
ScriptedExec::new()
|
|
2372 |
+ |
.on("codesign", 0, "")
|
|
2373 |
+ |
// Every attempt reports a still-pending status, never Accepted.
|
|
2374 |
+ |
.on("notarytool", 0, r#"{"status":"In Progress"}"#),
|
|
2375 |
+ |
);
|
|
2376 |
+ |
let recipe = r#"
|
|
2377 |
+ |
let h = build_host();
|
|
2378 |
+ |
step("build");
|
|
2379 |
+ |
sh_ok(h, "echo built");
|
|
2380 |
+ |
step("sign");
|
|
2381 |
+ |
codesign(h, "Developer ID Application: Test", "ARTIFACT");
|
|
2382 |
+ |
notarize(h, "ARTIFACT");
|
|
2383 |
+ |
step("publish");
|
|
2384 |
+ |
publish("tauri-mnw", "demo", "macos/aarch64", "0.0.1", "ARTIFACT", #{});
|
|
2385 |
+ |
"#;
|
|
2386 |
+ |
let (_tmp, pool, status, error) = run_macos_recipe(scripted.clone(), recipe, Some(0)).await;
|
|
2387 |
+ |
|
|
2388 |
+ |
assert_eq!(
|
|
2389 |
+ |
status, "failed",
|
|
2390 |
+ |
"exhausted notarization must fail the target"
|
|
2391 |
+ |
);
|
|
2392 |
+ |
assert!(
|
|
2393 |
+ |
error.contains("notarization failed after 3 attempts"),
|
|
2394 |
+ |
"error names the exhausted retry, got: {error}"
|
|
2395 |
+ |
);
|
|
2396 |
+ |
assert_eq!(
|
|
2397 |
+ |
release_count(&pool).await,
|
|
2398 |
+ |
0,
|
|
2399 |
+ |
"an unnotarized artifact never publishes"
|
|
2400 |
+ |
);
|
|
2401 |
+ |
let notary_calls = scripted
|
|
2402 |
+ |
.commands()
|
|
2403 |
+ |
.iter()
|
|
2404 |
+ |
.filter(|c| c.contains("notarytool"))
|
|
2405 |
+ |
.count();
|
|
2406 |
+ |
assert_eq!(notary_calls, 3, "the retry is bounded at three attempts");
|
|
2407 |
+ |
}
|
| 1942 |
2408 |
|
}
|
| 1943 |
2409 |
|
|
| 1944 |
2410 |
|
/// Every recipe of every configured app must parse.
|