Skip to content
Merged
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
10 changes: 10 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,16 @@ jobs:
run: cargo clippy --all-targets -- -D warnings
- name: cargo clippy (pcap feature)
run: cargo clippy --all-targets --features pcap -- -D warnings
# The desktop app's hand-written TypeScript types are checked against
# these generated ones at compile time
# (apps/netscli-gui/src/types/netscli.contract.ts). That check is only
# as good as the generated files, so they must match the Rust structs.
- name: TypeScript bindings match the Rust types
run: |
rm -rf apps/netscli-gui/src/types/generated
TS_RS_EXPORT_DIR="$PWD/apps/netscli-gui/src/types/generated" TS_RS_LARGE_INT=number cargo test -p netscli-core --features ts,mdns --lib export_bindings
git add --intent-to-add apps/netscli-gui/src/types/generated
git diff --exit-code -- apps/netscli-gui/src/types/generated || { echo "::error::Regenerate with the command in apps/netscli-gui/src/types/netscli.contract.ts"; exit 1; }

test:
name: Test (${{ matrix.os }})
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,12 @@ its heading and collects entries. The date and the link go on with the tag.
and only when the table holds such entries, and are listed only if they
answer. Devices that ignore ping still answer, so they are still found.

- **The desktop app's CSV export defuses control characters, as the CLI's
does.** A banner containing an escape sequence went into the file as it
was, and a cell such as a tab followed by a number skipped the guard that
stops a spreadsheet reading it as a formula. Both exports are now tested
against the same list of cases.

## [0.3.4] - 2026-10-04

### Added
Expand Down
60 changes: 46 additions & 14 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 16 additions & 0 deletions apps/netscli-cli/src/output/csv.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,22 @@ fn escape(cell: &str) -> String {
#[cfg(test)]
mod tests {
use super::*;

/// The cases the desktop app's `csvEscape` is tested against too, so the
/// two CSV exports quote and defuse a cell the same way.
#[test]
fn matches_the_shared_cases() {
let fixture: serde_json::Value =
serde_json::from_str(include_str!("../../../../testdata/csv-escape.json")).unwrap();
for case in fixture["cases"].as_array().unwrap() {
let input = case["in"].as_str().unwrap();
assert_eq!(
escape(input),
case["out"].as_str().unwrap(),
"input {input:?}"
);
}
}
use serde::Serialize;

#[derive(Serialize)]
Expand Down
14 changes: 14 additions & 0 deletions apps/netscli-gui/src/tools/presentation.csvShared.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { describe, expect, it } from 'vitest';

import fixture from '../../../../testdata/csv-escape.json';
import { csvEscape } from './presentation';

// The cases the CLI's `--csv` is tested against too (testdata/csv-escape.json),
// so the two CSV exports quote and defuse a cell the same way.
describe('csvEscape against the shared cases', () => {
for (const { in: input, out } of fixture.cases) {
it(`escapes ${JSON.stringify(input)}`, () => {
expect(csvEscape(input)).toBe(out);
});
}
});
22 changes: 20 additions & 2 deletions apps/netscli-gui/src/tools/presentation/values.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,29 @@ export function numberOrUndefined(value?: string): number | undefined {
* `\r` and tab are in the trigger set because both can carry a value onto a
* fresh line or cell where it would lead again.
*/
/** A control character other than the tab, CR and LF a CSV cell can carry.
* Unicode Cc, U+0000-U+001F and U+007F-U+009F, which is Rust's `is_control`. */
function isDefusedControl(char: string): boolean {
if (char === '\t' || char === '\r' || char === '\n') return false;
const code = char.codePointAt(0) ?? 0;
return code <= 0x1f || (code >= 0x7f && code <= 0x9f);
}

/** A finite decimal number, as Rust's `str::parse::<f64>` accepts it. */
const DECIMAL = /^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$/;

export function csvEscape(value: unknown): string {
const text = String(value ?? '');
// Control characters become `.`, as in the CLI's --csv, except tab, CR and
// LF, which a quoted cell carries and a multi-line banner needs. An escape
// sequence in a banner would otherwise run in a terminal that prints the
// file.
const text = Array.from(String(value ?? ''), (char) => (isDefusedControl(char) ? '.' : char)).join('');
// A cell that parses as a number cannot be a formula, so latency of -1 or
// a `+`-signed figure stays a number rather than gaining a stray quote.
const numeric = text !== '' && Number.isFinite(Number(text));
// A decimal literal only, as Rust's f64 parse in the CLI reads it:
// `Number()` also accepted surrounding whitespace, so a cell of "\t1"
// counted as a number and skipped the guard the CLI applies.
const numeric = DECIMAL.test(text);
const guarded = !numeric && /^[=+\-@\t\r]/.test(text) ? `'${text}` : text;
if (guarded.includes(',') || guarded.includes('"') || guarded.includes('\n') || guarded.includes('\r')) {
return `"${guarded.replace(/"/g, '""')}"`;
Expand Down
3 changes: 3 additions & 0 deletions apps/netscli-gui/src/types/generated/ArpEntry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.

export type ArpEntry = { ip: string, mac: string, interface: string, vendor: string | null, };
25 changes: 25 additions & 0 deletions apps/netscli-gui/src/types/generated/DnsRecord.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.

export type DnsRecord = {
/**
* Record type (e.g. "A", "AAAA", "MX"). Always upper-case.
*/
record_type: string,
/**
* Display-normalized value. For textual records we strip surrounding
* quotes and the trailing `.` that the resolver appends to FQDNs.
*/
value: string,
/**
* Owner name returned by the resolver, when available.
*/
name?: string,
/**
* Record TTL in seconds, when available.
*/
ttl_seconds?: number,
/**
* Resolver path used for this answer, for example "system" or
* "public_fallback".
*/
resolver_source?: string, };
11 changes: 11 additions & 0 deletions apps/netscli-gui/src/types/generated/FoundBy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.

/**
* How a host came to be in the results.
*
* Worth reporting rather than flattening, because the two carry different
* confidence. A host that answered a probe is definitely there now; a host
* known only from the neighbour table is one the OS has spoken to
* recently, which is usually but not always still true.
*/
export type FoundBy = "probe" | "neighbor";
14 changes: 14 additions & 0 deletions apps/netscli-gui/src/types/generated/Host.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { FoundBy } from "./FoundBy";
import type { NameSource } from "./NameSource";

export type Host = { ip: string, hostname: string | null, mac: string | null, vendor: string | null, rtt_ms: number | null,
/**
* Additive field: existing consumers that ignore it are unaffected.
*/
found_by: FoundBy,
/**
* Which lookup produced `hostname`, or `None` when there is no name.
* Additive, like `found_by`.
*/
hostname_source: NameSource | null, };
3 changes: 3 additions & 0 deletions apps/netscli-gui/src/types/generated/HttpHeader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.

export type HttpHeader = { name: string, value: string, };
4 changes: 4 additions & 0 deletions apps/netscli-gui/src/types/generated/HttpProbe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { HttpHeader } from "./HttpHeader";

export type HttpProbe = { status_line?: string, headers: Array<HttpHeader>, };
15 changes: 15 additions & 0 deletions apps/netscli-gui/src/types/generated/InspectResult.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { OsHint } from "./OsHint";
import type { PingResult } from "./PingResult";
import type { PortResult } from "./PortResult";

export type InspectResult = { host: string, ip: string | null, ping: PingResult | null, ports: Array<PortResult>, open_ports: Array<PortResult>, hostname: string | null,
/**
* From the local ARP/neighbour table, so only for a host on the same
* network segment.
*/
mac?: string, vendor?: string,
/**
* A best guess at the OS, with the clues behind it. See `os_hint`.
*/
os_hint?: OsHint, };
3 changes: 3 additions & 0 deletions apps/netscli-gui/src/types/generated/InterfaceInfo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.

export type InterfaceInfo = { name: string, mac: string | null, ips: Array<string>, is_up: boolean, is_loopback: boolean, };
Loading
Loading