Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .github/actions/use-ci-release/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,21 @@ runs:
using: composite
steps:
- name: Download release artifact
id: download
continue-on-error: true
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: moli-release-${{ inputs.side }}
path: target/ci-artifacts/${{ inputs.side }}
- name: Check downloaded archive completeness
id: archive
continue-on-error: true
shell: bash
env:
RELEASE_SIDE: ${{ inputs.side }}
run: gzip -t "target/ci-artifacts/$RELEASE_SIDE/moli-release-$RELEASE_SIDE.tar.gz"
- name: Retry incomplete release download
if: steps.download.outcome != 'success' || steps.archive.outcome != 'success'
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: moli-release-${{ inputs.side }}
Expand Down
3 changes: 3 additions & 0 deletions .github/scripts/unpack-ci-release.sh
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ esac
release_name="moli-release-$side"
archive="target/ci-artifacts/$side/$release_name.tar.gz"
test -f "$archive"
# A download action can terminate with a partial extracted archive. Reject it
# before touching the executable directory, including after the bounded retry.
gzip -t "$archive"
mkdir -p target/ci-bin
tar -xzf "$archive" -C target/ci-bin
(cd "target/ci-bin/$release_name" && sha256sum --check SHA256SUMS)
Expand Down
60 changes: 60 additions & 0 deletions .github/scripts/unpack-ci-release.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
'use strict';

const assert = require('node:assert/strict');
const { spawnSync } = require('node:child_process');
const { createHash } = require('node:crypto');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const test = require('node:test');

const script = path.join(__dirname, 'unpack-ci-release.sh');

function fixture(t) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'moli-ci-release-test-'));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const packageDir = path.join(root, 'package', 'moli-release-head');
const archive = path.join(root, 'target', 'ci-artifacts', 'head', 'moli-release-head.tar.gz');
fs.mkdirSync(packageDir, { recursive: true });
fs.mkdirSync(path.dirname(archive), { recursive: true });
const binary = Buffer.from('test release binary');
fs.writeFileSync(path.join(packageDir, 'moli'), binary);
fs.writeFileSync(path.join(packageDir, 'revision.txt'), 'expected-revision\n');
fs.writeFileSync(path.join(packageDir, 'SHA256SUMS'),
`${createHash('sha256').update(binary).digest('hex')} moli\n`);
function pack() {
const result = spawnSync('tar', ['-czf', archive, '-C', path.join(root, 'package'), 'moli-release-head']);
assert.equal(result.status, 0, String(result.stderr));
}
pack();
return { root, archive, packageDir, pack };
}

function unpack(root, revision = 'expected-revision') {
return spawnSync('bash', [script, 'head', revision], { cwd: root, encoding: 'utf8' });
}

test('valid archive passes payload and exact revision verification', (t) => {
const { root } = fixture(t);
const result = unpack(root);
assert.equal(result.status, 0, result.stderr);
assert.equal(fs.readFileSync(path.join(root, 'target/ci-bin/moli-release-head/moli'), 'utf8'), 'test release binary');
});

test('truncated download cannot write a partial executable; redownload recovers', (t) => {
const { root, archive } = fixture(t);
const complete = fs.readFileSync(archive);
fs.writeFileSync(archive, complete.subarray(0, complete.length - 16));
assert.notEqual(unpack(root).status, 0);
assert.equal(fs.existsSync(path.join(root, 'target/ci-bin')), false);
fs.writeFileSync(archive, complete);
assert.equal(unpack(root).status, 0);
});

test('complete downloads still reject wrong revisions and altered payloads', (t) => {
const { root, packageDir, pack } = fixture(t);
assert.notEqual(unpack(root, 'wrong-revision').status, 0);
fs.writeFileSync(path.join(packageDir, 'moli'), 'tampered payload');
pack();
assert.notEqual(unpack(root).status, 0);
});
55 changes: 50 additions & 5 deletions moli-cookie-import/src/sqlite.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
use std::{fs, io::ErrorKind, path::Path};
use std::{
fs,
io::ErrorKind,
path::{Path, PathBuf},
};

use anyhow::{Context, Result};
use rusqlite::{Connection, OpenFlags};
Expand All @@ -20,8 +24,7 @@ impl SqliteSnapshot {
fs::copy(source, &database)
.with_context(|| format!("failed to snapshot {label} `{}`", source.display()))?;
for suffix in ["-wal", "-shm"] {
let mut companion = source.as_os_str().to_owned();
companion.push(suffix);
let companion = sqlite_companion_path(source, suffix);
match fs::copy(
&companion,
directory.path().join(format!("database.sqlite{suffix}")),
Expand All @@ -47,6 +50,12 @@ impl SqliteSnapshot {
}
}

fn sqlite_companion_path(source: &Path, suffix: &str) -> PathBuf {
let mut companion = source.as_os_str().to_owned();
companion.push(suffix);
companion.into()
}

#[cfg(test)]
mod tests {
use super::*;
Expand All @@ -56,10 +65,33 @@ mod tests {
check_wal_snapshot(Path::new("cookies.sqlite"))
}

#[cfg(unix)]
#[cfg(target_os = "linux")]
#[test]
fn companion_paths_preserve_non_utf8_database_names() {
use std::os::unix::ffi::OsStrExt;

let source = Path::new(std::ffi::OsStr::from_bytes(b"cookies-\xff.sqlite"));
assert_eq!(
sqlite_companion_path(source, "-wal").as_os_str().as_bytes(),
b"cookies-\xff.sqlite-wal",
);
assert_eq!(
sqlite_companion_path(source, "-shm").as_os_str().as_bytes(),
b"cookies-\xff.sqlite-shm",
);
}

#[cfg(target_os = "macos")]
#[test]
fn snapshot_preserves_non_ascii_paths_when_finding_wal_files() -> Result<()> {
check_wal_snapshot(Path::new("cookies-é.sqlite"))
}

#[cfg(target_os = "linux")]
#[test]
fn snapshot_preserves_non_utf8_paths_when_finding_wal_files() -> Result<()> {
use std::os::unix::ffi::OsStrExt;

check_wal_snapshot(Path::new(std::ffi::OsStr::from_bytes(
b"cookies-\xff.sqlite",
)))
Expand All @@ -68,14 +100,27 @@ mod tests {
fn check_wal_snapshot(name: &Path) -> Result<()> {
let directory = tempfile::tempdir()?;
let source = directory.path().join(name);
let writer = Connection::open(&source)?;
// SQLite's filename conversion differs by platform. Create the WAL
// through an ordinary path, then rename its three files so this test
// isolates the snapshot's handling of non-UTF-8 source paths.
let writer_source = directory.path().join("cookies.sqlite");
let writer = Connection::open(&writer_source)?;
writer.execute_batch(
"PRAGMA journal_mode=WAL;
PRAGMA wal_autocheckpoint=0;
CREATE TABLE entries (value TEXT);
PRAGMA wal_checkpoint(TRUNCATE);
INSERT INTO entries VALUES ('only in WAL');",
)?;
if source != writer_source {
for suffix in ["", "-wal", "-shm"] {
let mut original = writer_source.as_os_str().to_owned();
original.push(suffix);
let mut renamed = source.as_os_str().to_owned();
renamed.push(suffix);
fs::rename(Path::new(&original), Path::new(&renamed))?;
}
}
let files = fs::read_dir(directory.path())?
.map(|entry| {
let path = entry?.path();
Expand Down
23 changes: 21 additions & 2 deletions moli-curl/src/proxy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -519,7 +519,10 @@ mod tests {

#[test]
fn no_proxy_routes_match_libcurl() {
use std::{net::TcpListener, time::Duration};
use std::{
net::TcpListener,
time::{Duration, Instant},
};

use curl::easy::{Easy, List};

Expand Down Expand Up @@ -576,7 +579,23 @@ mod tests {
assert!(connected_port == origin_port || connected_port == proxy_port);
let curl_uses_proxy = connected_port == proxy_port;
let listener = if curl_uses_proxy { &proxy } else { &origin };
let _connection = listener.accept().unwrap();
// Client connect completion does not guarantee the server's
// nonblocking accept queue is observable in the same turn.
let deadline = Instant::now() + Duration::from_secs(2);
let _connection = loop {
match listener.accept() {
Ok(connection) => break connection,
Err(error)
if matches!(
error.kind(),
std::io::ErrorKind::WouldBlock | std::io::ErrorKind::Interrupted
) && Instant::now() < deadline =>
{
std::thread::sleep(Duration::from_millis(5));
}
Err(error) => panic!("libcurl's selected endpoint did not accept: {error}"),
}
};

for (scheme, proxy_env) in [
("http", "http_proxy"),
Expand Down
73 changes: 72 additions & 1 deletion moli-fetch/src/tests/support.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1003,14 +1003,85 @@ impl ScriptedHttpServer {
}
}

#[test]
fn scripted_server_waits_for_request_on_nonblocking_accepted_socket() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let mut client = TcpStream::connect(listener.local_addr().unwrap()).unwrap();
client
.set_read_timeout(Some(Duration::from_secs(2)))
.unwrap();
let (stream, _) = listener.accept().unwrap();
// macOS may inherit this mode from the listening socket.
stream.set_nonblocking(true).unwrap();
let hits = Arc::new(AtomicUsize::new(0));
let requests = Arc::new(Mutex::new(Vec::new()));
let responses = Arc::new(Mutex::new(VecDeque::from([ScriptedResponse::ok("ready")])));
let (done_tx, done_rx) = std_mpsc::channel();
let handler = {
let hits = Arc::clone(&hits);
let requests = Arc::clone(&requests);
thread::spawn(move || {
handle_scripted_connection(stream, hits, requests, responses);
done_tx.send(()).unwrap();
})
};
assert!(
matches!(
done_rx.recv_timeout(Duration::from_millis(100)),
Err(std_mpsc::RecvTimeoutError::Timeout)
),
"a connection without HTTP bytes must not receive a scripted response"
);
client
.write_all(b"GET /delayed HTTP/1.1\r\nHost: localhost\r\n\r\n")
.unwrap();
let mut response = String::new();
client.read_to_string(&mut response).unwrap();
handler.join().unwrap();
assert!(response.ends_with("ready"));
assert_eq!(hits.load(Ordering::SeqCst), 1);
assert!(requests.lock()[0].starts_with("GET /delayed HTTP/1.1\r\n"));
}

#[test]
fn scripted_server_does_not_consume_response_for_closed_unused_connection() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let client = TcpStream::connect(listener.local_addr().unwrap()).unwrap();
let (stream, _) = listener.accept().unwrap();
drop(client);
let hits = Arc::new(AtomicUsize::new(0));
let requests = Arc::new(Mutex::new(Vec::new()));
let responses = Arc::new(Mutex::new(VecDeque::from([ScriptedResponse::ok("ready")])));
handle_scripted_connection(
stream,
Arc::clone(&hits),
Arc::clone(&requests),
Arc::clone(&responses),
);
assert_eq!(hits.load(Ordering::SeqCst), 0);
assert!(requests.lock().is_empty());
assert_eq!(responses.lock().len(), 1);
}

fn handle_scripted_connection(
mut stream: std::net::TcpStream,
hits: Arc<AtomicUsize>,
requests: Arc<Mutex<Vec<String>>>,
responses: Arc<Mutex<VecDeque<ScriptedResponse>>>,
) {
stream
.set_nonblocking(false)
.expect("scripted HTTP connection should use blocking reads");
stream
.set_read_timeout(Some(Duration::from_secs(5)))
.expect("scripted HTTP connection should have a bounded read");
let mut request = [0; 1024];
let bytes_read = stream.read(&mut request).unwrap_or(0);
let bytes_read = stream
.read(&mut request)
.expect("read scripted HTTP request");
if bytes_read == 0 {
return;
}
let request_text = String::from_utf8_lossy(&request[..bytes_read]).into_owned();
requests.lock().push(request_text);
let _ = hits.fetch_add(1, Ordering::SeqCst) + 1;
Expand Down
41 changes: 38 additions & 3 deletions moli-layout/tests/phase4_layout_contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1564,9 +1564,44 @@ fn inline_blocks_use_their_internal_last_line_baseline_and_overflow_fallback() {
.primary
.insert(5, nav_item_style(BLUE, 19.0, Overflow::Hidden));
let fallback = render(&source, &mut styles, 300, 100);
assert_close(rect(&fallback, RED).y, 45.0);
assert_close(rect(&fallback, GREEN).y, 45.0);
assert_close(rect(&fallback, BLUE).y, 19.0);
let fallback_news = rect(&fallback, RED);
let fallback_hao = rect(&fallback, GREEN);
let fallback_more = rect(&fallback, BLUE);
let glyph_baselines = fallback
.fragments
.iter()
.filter_map(|fragment| match fragment {
moli_layout::PaintFragment::GlyphRun(run) => run.glyphs.first().map(|glyph| {
run.transform
.map_point(moli_layout::LayoutPoint::new(glyph.x, glyph.y))
.y
}),
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(glyph_baselines.len(), 3);
assert_close(glyph_baselines[0], fallback_more.bottom());
assert_close(glyph_baselines[1], fallback_more.bottom());
assert_close(fallback_news.y, fallback_hao.y);
assert!(fallback_news.y > news.y);
assert_close(fallback_more.y, more.y);

// The scrolling inline-block synthesizes its baseline from its margin-box
// edge. Increasing only its block-end padding must therefore move both
// text-backed peers by exactly the same amount while its own top remains
// fixed. This checks the fallback contract without baking a platform font's
// ascent into an absolute y coordinate.
styles
.primary
.insert(5, nav_item_style(BLUE, 29.0, Overflow::Hidden));
let deeper_fallback = render(&source, &mut styles, 300, 100);
assert_close(rect(&deeper_fallback, RED).y, fallback_news.y + 10.0);
assert_close(rect(&deeper_fallback, GREEN).y, fallback_hao.y + 10.0);
assert_close(rect(&deeper_fallback, BLUE).y, fallback_more.y);
assert_close(
rect(&deeper_fallback, BLUE).height,
fallback_more.height + 10.0,
);
}

#[test]
Expand Down
Loading
Loading