diff --git a/CLAUDE.md b/CLAUDE.md index 19d8e76..7043c79 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -315,7 +315,11 @@ builds a library in this box's own architecture that needs `libm`, `libc` and a exactly the blocked library's two symbols (a function and a data object), the refusal with the loader's words, nothing that another library provides, and no weak import — taken inline and again on a thread of its own with the caller -waiting, which is how `OnCreate` runs it ahead of the engine. The committed ARM +waiting, which is how `OnCreate` runs it ahead of the engine. A second shape holds +the second pass: a library that imports a data symbol from a sibling it does not +name in its own `DT_NEEDED` is held back, loaded once the rest are in, and never +counted as refused or its exports as a stub's work — the AU7200's first census put +twenty-three C++ names on the list that way. The committed ARM `libovprobe.so` exercises the ELF32 path, a FIFO holds it to a recorded miss rather than a hang, and a second call must change nothing. Needs the .NET 6 SDK under `~/.dotnet-local` and a C compiler. @@ -339,18 +343,27 @@ every stall it has ever shown was a call immediately followed by a trail write; trail written on the caller's thread could not tell the two apart. Needs only the .NET 6 SDK under `~/.dotnet-local`. -### The native probe library +### The native libraries -`Overscan5/res/libovprobe.so` is the only native binary this repo ships: a tiny ARM -shared object that exists so issue #17's set can be asked whether this app may load -native code of its own at all. It is **committed**, because CI has no ARM toolchain. -It ships three times from that one file — `res/` needs no `.csproj` item, a `` -item puts a copy in `bin/` (the directory the app's own assembly lives in, and the -only one on that set known to map a file of ours executable), and a -`TizenTpkUserIncludeFiles` item with `TizenTpkSubDir` puts one in `lib/`. All three -are covered by both signatures. +Two tiny ARM shared objects are the only native binaries this repo ships, both +**committed** because CI has no ARM toolchain: -Rebuild it only if `tools/elfprobe/ovprobe.s` changes: +- `Overscan5/res/libovprobe.so` exists so issue #17's set can be asked whether this + app may load native code of its own at all. It ships three times from that one + file — `res/` needs no `.csproj` item, a `` item puts a copy in `bin/` (the + directory the app's own assembly lives in, and the only one on that set known to + map a file of ours executable), and a `TizenTpkUserIncludeFiles` item with + `TizenTpkSubDir` puts one in `lib/`. All three are covered by both signatures. + `Overscan6` references the same file. +- `Overscan6/res/libprivileged-service-client.so` is issue #105's stub: the four + no-op C functions the engine imports from the library the firmware will not let + an app open, under that soname. `EngineStub` loads it `RTLD_GLOBAL` before the + engine, and **only** on an install whose ledger says the engine already failed + there, at most twice if the launch never comes back with it in. INTERNALS, *The + census came back, and the stub ships*, has the readings. Only the tizen6 package + carries it. + +Rebuild them only if `tools/elfprobe/ovprobe.s` or `tools/elfprobe/psstub.s` changes: ```sh apt-get download binutils-arm-linux-gnueabihf # no root needed @@ -358,9 +371,13 @@ dpkg-deb -x binutils-arm-linux-gnueabihf_*.deb /some/where XTOOL=/some/where tools/elfprobe/build.sh ``` -That script prints the ELF header and dynamic section at the end — check it still -says `DYN`, `ARM`, `SONAME libovprobe.so` and no `NEEDED`. A dependency on anything -would make a refusal to load ambiguous, which defeats the whole measurement. +That script prints each file's ELF header, dynamic section and exports at the end — +check each still says `DYN`, `ARM`, its own `SONAME`, and no `NEEDED` and no +`TEXTREL`. A dependency on anything would make a refusal to load ambiguous, which +defeats the whole measurement, and a text relocation asks the loader for a +writable code page on a firmware that refuses far less. Rebuilding with a different +binutils changes the probe library's bytes without changing what it does; commit +only the file whose source changed. ## Releasing diff --git a/Overscan6/res/libprivileged-service-client.so b/Overscan6/res/libprivileged-service-client.so new file mode 100755 index 0000000..5dcdd85 Binary files /dev/null and b/Overscan6/res/libprivileged-service-client.so differ diff --git a/docs/INTERNALS.md b/docs/INTERNALS.md index 3b3be2f..c0d2b71 100644 --- a/docs/INTERNALS.md +++ b/docs/INTERNALS.md @@ -1277,7 +1277,9 @@ good one; `build-283f3e9` shipped the census and the AU7200 never reached it; census running on this set, nineteen libraries in, with the answer not yet reached. The next page needs no install: a relaunch, left alone until the summary screen, and the `previous run` block carries the finished count. Nothing here changes the Q80's -close: that set refused the mapping the AU7200 allows. +close: that set refused the mapping the AU7200 allows. **Superseded the next +morning** — the relaunch page came, the count is read, and the stub ships: see *The +census came back, and the stub ships* below, which is where #105's state now lives. **The page sent for it (2026-10-02, 13:49 UTC) was not from `build-e1a648d`.** It was the attachment from the issue's first post, byte for byte (`tv_logs.txt`, 102 lines, @@ -1351,6 +1353,139 @@ whole page. How to read it is unchanged from above, with what this page adds: the way the walk does before each rung, so that page could not say which library; the next build adds that line, and that is the one change it makes. +### The census came back, and the stub ships + +The `build-e1a648d` page the reporter sent on 2026-10-03 (07:13 UTC, after one +relaunch, no install) is the finished count. Its `previous run` is a launch on +2026-10-02 at 16:52 (the TV's clock), and between `OnCreate: UI built` and the EFL +ladder it carries the whole census, ahead of the engine, in 1421 ms: + +``` +engine imports: ELF32 libchromium-impl.so: 115 needed, 2520 imports (5 weak) +engine imports: refused libwgt-manifest-handlers.so.1 — /lib/libwgt-manifest-handlers.so.1: undefined symbol: _ZTIN6parser15ManifestHandlerE +engine imports: refused libprivileged-service-client.so — libprivileged-service-client.so: cannot open shared object file: Operation not permitted +engine imports: 27 unresolved (23 C++) — what a stub must provide +engine imports: census finished after 1421 ms — on to the engine +``` + +Then the engine, line for line as before — nine EFL subsystems, `ewk_init` 0 twice, +the implementation refused on the same soname — the failure screen, the ladder's +verdict already on the books, the permission probe through to `probe: done`, the +heartbeat's `+10s` and `+20s`, and `app loop returned` at 16:53:01: the reporter +closing it. A whole launch, nothing parked, and the `this run` block is a launch +that reached the same place again the next morning. Read by the list written above +it is **two readings at once**, and telling them apart is the whole point of what +the census records beside each name: + +- **The twenty-three C++ names are not the blocked library's.** Every one is in + `wgt::parse` (`WidgetConfigParser`, `MetaDataInfo`, `CSPInfo::Key` …), which is + the widget-manifest handler library — `libwgt-manifest-handlers.so.1`, the + *other* refusal, and its refusal is not a wall: `undefined symbol: + _ZTIN6parser15ManifestHandlerE` is the typeinfo of `parser::ManifestHandler`, + which `libmanifest-parser.so.1` exports, and that library is on the same + DT_NEEDED list, *after* it, and loaded. The census opens each library alone and + `RTLD_LOCAL`; the engine's loader opens the whole list into one scope and the + typeinfo resolves. A library underlinked against its sibling is a build habit of + the platform, not a gate, and the loader's own words say which it is. +- **The four plain C names are.** `PS_Mknod`, `PS_Mount`, `PS_Umount`, + `PS_ErrorToString`, all `FUNC`, no data, nothing C++: the privileged-service + client's whole surface as the engine uses it. That is the first reading in the + list above — *a no-op stub is buildable, and the build after it ships the stub*. + +Two things changed for it. **The census now takes a second pass.** A library +refused for an `undefined symbol` is held back rather than refused (`held back X — +… (asked again after the rest)`), and once the rest have loaded they are promoted to +the global scope with `RTLD_NOLOAD|RTLD_GLOBAL` — nothing new is loaded, the flags +change — and the held-back library is opened again. One that loads then says so +(`loaded X on the second pass — the symbol was another needed library's, not a +wall`) and its exports resolve like anyone else's; one that still fails is refused +with the second pass named. The wider scope only ever happens on an install whose +engine has already failed, since that is the only install the census runs on. +`tools/engineimports`'s `underlinked` scenario builds that exact shape — a library +importing a data symbol from a sibling it does not name — and holds the census to +0 refused, 0 unresolved. The next page from the AU7200 should read `115 needed (1 +refused: libprivileged-service-client.so), 2520 imports, 4 unresolved: PS_Mknod +(FUNC), PS_Mount (FUNC), PS_Umount (FUNC), PS_ErrorToString (FUNC)`. + +**And the stub ships.** `Overscan6/res/libprivileged-service-client.so`, 5 KB, +built from `tools/elfprobe/psstub.s` by the same recipe as the probe library +(`tools/elfprobe/build.sh` now builds both): `DYN`, `ARM`, EABI5 soft-float like +the engine's own header, `SONAME libprivileged-service-client.so` — unversioned, +because that is how the implementation names it — no `NEEDED`, and no `TEXTREL` +(the one string it returns is addressed PC-relative, inside `.text`, so the loader +never has to make a code page writable on a firmware that polices mappings). Each +of the four functions returns 0; `PS_ErrorToString` returns an empty string. The +privileged-service calls are what the engine would use to set up a sandbox mount a +browser-only app has no use for, and "done, nothing happened" is the answer most +likely to let it carry on; an error would come back as the failure we already have. + +`EngineStub` (`src/common`) is what loads it, from `OnCreate` after the census and +before `TryStartEngine`: `dlopen` by absolute path, `RTLD_NOW|RTLD_GLOBAL`, under a +`Deadline`, with `PS_Mount` resolved from the handle as proof it is ours. glibc +checks the sonames already in the process before it searches for a `DT_NEEDED`, +so when `ewk_init` loads the implementation the loader finds +`libprivileged-service-client.so` satisfied and never opens the file in `/usr/lib` +that it may not. It is **not a diagnostic**, so the rule that puts diagnostics +behind the engine does not apply — it is the thing being tried and has to be in +front — and two gates stand in for that rule: + +- **Never on a set where the engine starts.** The gate is + `NativeProbe.EngineFailedHere()`, the ledger that only comes into existence on a + launch whose engine failed. On every set this app works on the header says + `engine stub: shipped, not loaded — the engine has not failed on this install` + and nothing else happens. A stub shadowing a real library on a working set would + be the worst thing this build could do, and this is what makes it impossible. +- **At most twice on a set where it does not help.** `engine-stub.txt` in `data/` + gets a `trying` line before the dlopen and a `came back ` line once the + engine has answered, started or failed. A launch that loads the stub and then + dies inside `ewk_init` — the Q80's shape, a second after the call — never writes + the second line; two `trying` lines with nothing behind them and the stub is + withheld (`engine stub: withheld — 2 launches never came back from the engine with + it in`), the failure screen is back, and the report says why. The ladder's own + rule for its rungs, for the same reason: a fix that kills every launch is a brick, + and from the sofa a brick and a dead set look the same. + +The report has an `engine stub:` header line on both pages (the full one and the +no-provider one); the trail has `engine stub: loading () RTLD_NOW|RTLD_GLOBAL` +before the call and the result after it. Only the tizen6 package carries the file: +the AU7200 is the only set that has passed the third gate, the Q80 (tizen5) refused +every mapping, and the NUI sets have no wall. + +How to read the next report, said here first: + +- **`engine stub: loaded from …`, then `Chromium initialized, refcount=1` (or any + positive count), then `engine UA:` and a page** — the AU7200 runs Overscan. The + reply says so, and the follow-up is whatever the browser then does on that set, + as a new issue or on this one. +- **`engine stub: loaded from …`, then the engine failing on a *different* line** — + `dlopen failed:` naming another library, or an `undefined symbol` — the stub did + its job and the wall has a second layer; the name on that line is the next + question, and the census (now four names clean) says whether a second stub is a + short list of C functions or not. +- **`engine stub: loaded from …`, then the same `libprivileged-service-client.so: + Operation not permitted`** — the loader went to `/usr/lib` regardless, so the + soname match did not happen in this firmware's loader (an `RTLD_GLOBAL` handle in + the app's namespace not being consulted for the engine's `dlopen`, or the + implementation loaded in a way that bypasses it). That is the end of the stub + route as designed, and the only variant left is the one `ChromiumImpl` already + does between the two attempts: open the implementation ourselves, by absolute + path, after the stub. +- **`engine stub: loading …` as the launch's last line, or `Chromium.Initialize()` + right behind it with no `Chromium initialized`** — the stub, or the engine with + the stub in, ended the launch. The next launch tries once more and says so + (`attempt 2, the last launch with it in never came back`); the one after withholds + it. Two launches, then the report reads as before and the set is where #95 is. +- **`engine stub: refused — …`** — our own library would not load on a path the + ladder said maps executable; the loader's words on that line are the finding. + +**State of #105 (2026-10-03):** the census is read, the stub route is the four +names above, and the stub ships in the build this section was written for. The +reply asks for an install of that build's `Overscan-tizen6.tpk`, two launches (the +first is the one that loads the stub, since the ledger already exists on his +install; the second serves the page that shows it), and the whole `:8081` page — +and says in advance which of the five readings above means the AU7200 runs +Overscan and which means it does not. + ### `ELM_ACCEL` has to be set before the window exists `libchromium-ewk.so` has a library constructor whose entire body is diff --git a/src/common/DiagServer.cs b/src/common/DiagServer.cs index fa513f9..6d77685 100644 --- a/src/common/DiagServer.cs +++ b/src/common/DiagServer.cs @@ -114,6 +114,7 @@ private static string Report() "build : " + BuildInfo.Tag + "\n" + "own native : " + NativeProbe.Summary + "\n" + "engine imports: " + EngineImports.Summary + "\n" + + "engine stub: " + EngineStub.Summary + "\n" + "trail write: " + Breadcrumbs.Status + "\n\n" + "this run\n" + DiagLog.Dump() + "\nwhat a stub must provide (the implementation's imports)\n" + EngineImports.Dump() + diff --git a/src/common/EngineImports.cs b/src/common/EngineImports.cs index b693f0e..0d27e65 100644 --- a/src/common/EngineImports.cs +++ b/src/common/EngineImports.cs @@ -65,6 +65,8 @@ internal static class EngineImports { private const int RtldLazy = 0x0001; private const int RtldLocal = 0x0000; + private const int RtldNoload = 0x0004; + private const int RtldGlobal = 0x0100; /// How many unresolved names the trail carries; the dump has them all. private const int TrailNames = 40; @@ -282,12 +284,14 @@ private static void Run(string implementation, string blocked) // then by path in the engine's directories, which the loader does not // search for us. One refusal line each; the ones that load are not news. var handles = new List>(); + var openedAs = new Dictionary(StringComparer.Ordinal); var refused = new List(); + var heldBack = new List(); string directory = Path.GetDirectoryName(path) ?? "/usr/lib"; foreach (string soname in elf.Needed) { IntPtr handle = IntPtr.Zero; - string error = null; + string error = null, used = soname; string answer = Deadline.Run(delegate { handle = Open(soname, out error); @@ -296,6 +300,7 @@ private static void Run(string implementation, string blocked) string here = Path.Combine(directory, soname); if (File.Exists(here)) { + used = here; handle = Open(here, out error); } } @@ -306,15 +311,95 @@ private static void Run(string implementation, string blocked) if (answer == "loaded") { handles.Add(new KeyValuePair(soname, handle)); + openedAs[soname] = used; Note(" loaded: " + soname); continue; } string why = answer == Deadline.Missed ? Deadline.Missed : (error ?? answer); + + // "undefined symbol" is not a wall, it is an order. A library built + // against a sibling it does not name in its own DT_NEEDED resolves + // fine when the engine loads it — everything on the implementation's + // list is in one global scope by then — and fails here, where each + // one is opened alone and RTLD_LOCAL. build-e1a648d's census on the + // AU7200 had one of these, libwgt-manifest-handlers.so.1 missing a + // typeinfo that libmanifest-parser.so.1 (later on the same list) + // provides, and its twenty-three wgt::parse exports then counted + // as what a stub must provide. They are not. Such a library is set + // aside and asked again once the rest have loaded, below. + if (error != null && error.IndexOf("undefined symbol", StringComparison.Ordinal) >= 0) + { + heldBack.Add(soname); + Trace("held back " + soname + " — " + why + " (asked again after the rest)"); + continue; + } + refused.Add(soname + " — " + why); Trace("refused " + soname + " — " + why); } + // Step 2b: the second pass. Everything that loaded is promoted to the + // global scope first — RTLD_NOLOAD loads nothing, it only changes the + // flags of a library already in the process — so a held-back library's + // data relocations can find a sibling the way they do under the engine. + // Only ever reached on an install whose engine has already failed, so + // the wider scope costs a working engine nothing. + if (heldBack.Count > 0) + { + string promoted = Deadline.Run(delegate + { + foreach (KeyValuePair pair in handles) + { + string name; + if (openedAs.TryGetValue(pair.Key, out name)) + { + dlopen(name, RtldLazy | RtldNoload | RtldGlobal); + } + } + + return "ok"; + }, 30000); + if (promoted != "ok") + { + Trace("second pass: promoting the loaded libraries " + promoted); + } + + foreach (string soname in heldBack) + { + IntPtr handle = IntPtr.Zero; + string error = null, used = soname; + string answer = Deadline.Run(delegate + { + handle = Open(soname, out error); + if (handle == IntPtr.Zero && IsNotFound(error)) + { + string here = Path.Combine(directory, soname); + if (File.Exists(here)) + { + used = here; + handle = Open(here, out error); + } + } + + return handle == IntPtr.Zero ? "refused" : "loaded"; + }); + + if (answer == "loaded") + { + handles.Add(new KeyValuePair(soname, handle)); + openedAs[soname] = used; + Trace("loaded " + soname + " on the second pass — the symbol was another needed library's, not a wall"); + Note(" loaded: " + soname + " (second pass)"); + continue; + } + + string why = answer == Deadline.Missed ? Deadline.Missed : (error ?? answer); + refused.Add(soname + " — " + why); + Trace("refused " + soname + " — " + why + " (second pass)"); + } + } + // Step 3: the process first (libc and everything already in it), then // each library that loaded. Weak imports are the loader's business, not // a stub's; they are set aside rather than asked. diff --git a/src/common/EngineStub.cs b/src/common/EngineStub.cs new file mode 100644 index 0000000..426852e --- /dev/null +++ b/src/common/EngineStub.cs @@ -0,0 +1,304 @@ +using System; +using System.Globalization; +using System.IO; +using System.Runtime.InteropServices; +using Tizen.Applications; + +namespace Overscan +{ + /// + /// Puts a stand-in libprivileged-service-client.so into the process before + /// the engine goes looking for the real one — issue #105's fix, if it is one. + /// + /// The wall on Samsung's retail firmware from 5.5 through 6.0 (issues #17, #95, + /// #105) is one library: libchromium-impl.so needs + /// libprivileged-service-client.so, and an app may not open that file — + /// open(O_RDONLY) is EPERM, above Smack, lifted by no manifest privilege. + /// The implementation itself is readable, and the AU7200 (#105) is the first set + /// to let us map a library of our own executable, so the route written down in + /// docs/INTERNALS.md (*What is left on the Q80*) is open there: ship a library + /// under that soname, dlopen it RTLD_GLOBAL by absolute path before + /// ewk_init, and when the loader walks the implementation's DT_NEEDED it + /// finds the soname already in the process and never goes to /usr/lib for the + /// file it may not open. build-e1a648d's import census said what the stub + /// has to export: four plain C functions (PS_Mknod, PS_Mount, + /// PS_Umount, PS_ErrorToString), no data, nothing C++ — see + /// and tools/elfprobe/psstub.s, which is the + /// whole library. + /// + /// When it loads is the whole safety of it. **Never on a set where the engine + /// starts on its own:** the gate is , + /// the ledger that only comes into existence on a launch whose engine failed, so + /// a working set never has a stub shadowing its real library. And **at most twice + /// on a set where it does not help:** a launch that loads the stub and then never + /// comes back from the engine (the Q80's shape — the process ending a second + /// after ewk_init) leaves a trying line with no came back + /// behind it in ; two of those and the stub is withheld, + /// the failure screen returns, and the report says so. The same rule the + /// ladder's rungs live by (NativeProbe.FatalAfter), for the same reason: a + /// diagnostic that kills every launch is a brick, and a reporter cannot tell a + /// brick from a dead set. + /// + /// Everything here is best-effort and under a : a stub that + /// cannot be loaded is recorded by name and the engine is asked without it. + /// + internal static class EngineStub + { + /// The soname the engine's implementation needs, and the file's name in res/. + public const string Soname = "libprivileged-service-client.so"; + + /// One symbol it exports, to prove the handle is the stub and not the firmware's file. + private const string ProofSymbol = "PS_Mount"; + + /// + /// Where the attempts are kept, in the one directory that survives a launch. + /// One line per event: trying before the dlopen, came back<tab>why + /// once the engine has answered with the stub in. A trying with no + /// came back behind it is a launch that ended with the stub in. + /// + private const string LedgerFile = "engine-stub.txt"; + + /// How many launches may end with the stub in before it is withheld. + private const int GiveUpAfter = 2; + + private const int RtldNow = 0x0002; + private const int RtldGlobal = 0x0100; + + [DllImport("libdl.so.2")] + private static extern IntPtr dlopen(string file, int mode); + + [DllImport("libdl.so.2")] + private static extern IntPtr dlerror(); + + [DllImport("libdl.so.2")] + private static extern IntPtr dlsym(IntPtr handle, string symbol); + + private static bool _asked; + private static string _ledger; + + /// One line for the report's header. + public static string Summary { get; private set; } = "(not asked yet)"; + + /// True once the stub is in the process under its soname. + public static bool Loaded { get; private set; } + + /// + /// Loads the stub if this install is one it is for. Call after the import + /// census and before the engine; a second call does nothing. + /// + public static void Preload() + { + if (_asked) + { + return; + } + + _asked = true; + try + { + Attempt(); + } + catch (Exception ex) + { + Summary = "failed before the call — " + ex.GetType().Name + ": " + ex.Message; + Breadcrumbs.Drop("engine stub: " + Summary); + } + } + + /// + /// The engine has answered — started, or failed in the ordinary way — with the + /// stub in the process. Written down so the next launch knows this one came + /// back; a launch that dies inside the engine never gets here, which is the + /// point. Nothing to write when the stub was not loaded. + /// + public static void EngineAnswered(string what) + { + if (!Loaded) + { + return; + } + + Record("came back\t" + (what ?? "(no word)")); + } + + private static void Attempt() + { + string file = Locate(); + if (file == null) + { + Summary = "not shipped in this package"; + return; + } + + if (!NativeProbe.EngineFailedHere()) + { + // The ordinary case on every set this app works on. Not a trail line: + // nothing happened, and the header says why. + Summary = "shipped, not loaded — the engine has not failed on this install"; + return; + } + + string data = DataDirectory(); + _ledger = string.IsNullOrEmpty(data) ? null : Path.Combine(data, LedgerFile); + int abandoned = Abandoned(); + if (abandoned >= GiveUpAfter) + { + Summary = "withheld — " + abandoned.ToString(CultureInfo.InvariantCulture) + + " launches never came back from the engine with it in"; + Breadcrumbs.Drop("engine stub: " + Summary); + return; + } + + long size = -1; + try + { + size = new FileInfo(file).Length; + } + catch (Exception) + { + // The size is for the reader; the dlopen is the measurement. + } + + Breadcrumbs.Drop("engine stub: loading " + file + + (size >= 0 ? " (" + size.ToString(CultureInfo.InvariantCulture) + " bytes)" : string.Empty) + + " RTLD_NOW|RTLD_GLOBAL" + + (abandoned > 0 + ? " — attempt " + (abandoned + 1).ToString(CultureInfo.InvariantCulture) + + ", the last launch with it in never came back" + : string.Empty)); + Record("trying"); + + string error = null; + string answer = Deadline.Run(delegate + { + dlerror(); + IntPtr handle = dlopen(file, RtldNow | RtldGlobal); + if (handle == IntPtr.Zero) + { + IntPtr message = dlerror(); + error = message == IntPtr.Zero ? "dlopen failed without a message" : Marshal.PtrToStringAnsi(message); + return "refused"; + } + + return dlsym(handle, ProofSymbol) != IntPtr.Zero + ? "loaded" + : "loaded, but " + ProofSymbol + " did not resolve from it"; + }); + + if (answer == "loaded") + { + Loaded = true; + Summary = "loaded from " + file + " — the loader has " + Soname + + " from us; the engine is asked with it in"; + } + else if (answer == Deadline.Missed) + { + // The dlopen itself parked. The trying line stays unanswered, which + // is what the next launch should count. + Summary = "DID NOT RETURN — dlopen of " + file; + } + else + { + Summary = "refused — " + (error ?? answer); + Record("came back\tnot loaded: " + Summary); + } + + Breadcrumbs.Drop("engine stub: " + Summary); + } + + /// Launches that loaded the stub and never wrote that the engine answered. + private static int Abandoned() + { + if (_ledger == null) + { + return 0; + } + + int trying = 0, cameBack = 0; + string read = Deadline.Run(delegate + { + if (!File.Exists(_ledger)) + { + return "ok"; + } + + foreach (string line in File.ReadAllLines(_ledger)) + { + if (line.StartsWith("trying", StringComparison.Ordinal)) + { + trying++; + } + else if (line.StartsWith("came back", StringComparison.Ordinal)) + { + cameBack++; + } + } + + return "ok"; + }); + + if (read != "ok") + { + // A ledger that will not open is written off: the stub is tried as if + // for the first time, and the trail says the books were unreadable. + Breadcrumbs.Drop("engine stub: ledger unreadable (" + read + ") — counting from zero"); + return 0; + } + + return Math.Max(0, trying - cameBack); + } + + private static void Record(string line) + { + if (_ledger == null) + { + return; + } + + string wrote = Deadline.Run(delegate + { + File.AppendAllText(_ledger, line + "\n"); + return "ok"; + }); + + if (wrote != "ok") + { + Breadcrumbs.Drop("engine stub: could not write '" + line + "' to the ledger (" + wrote + ")"); + } + } + + /// The stub's path in res/, or null when this package does not ship one. + private static string Locate() + { + try + { + var info = Application.Current == null ? null : Application.Current.DirectoryInfo; + string resource = info == null ? null : info.Resource; + if (string.IsNullOrEmpty(resource)) + { + return null; + } + + string path = Path.Combine(resource, Soname); + return File.Exists(path) ? path : null; + } + catch (Exception) + { + return null; + } + } + + private static string DataDirectory() + { + try + { + var info = Application.Current == null ? null : Application.Current.DirectoryInfo; + return info == null ? null : info.Data; + } + catch (Exception) + { + return null; + } + } + } +} diff --git a/src/elm/BrowserApp.cs b/src/elm/BrowserApp.cs index 53b68c9..8c30d13 100644 --- a/src/elm/BrowserApp.cs +++ b/src/elm/BrowserApp.cs @@ -214,6 +214,17 @@ protected override void OnCreate() : "engine imports: still counting after " + (CensusBudgetMs / 1000) + " s — on to the engine anyway"); } + // The census's answer, applied (issue #105): a stand-in for the one + // library the engine needs and the firmware will not let us open, put + // into the process under its soname before ewk_init goes looking. Not a + // diagnostic, so the rule that puts those behind the engine does not + // apply — it is the thing being tried, and it has to be in front. Its + // own gate keeps it off every set where the engine starts, and its own + // ledger withholds it after two launches that never came back with it + // in. After the census, so the census still measures the firmware and + // not us. See EngineStub. + EngineStub.Preload(); + // Bringing up chromium-efl is the one step we expect to be able to // fail: there are reports of an app-created Tizen.WebView crashing on // the TV emulator with nothing in the log, and the TV profile is not @@ -236,6 +247,11 @@ protected override void OnCreate() started = false; } + // Either way the engine answered, which with the stub in is the fact + // the next launch needs: a launch that dies inside ewk_init never + // writes this line, and two of those withhold the stub. + EngineStub.EngineAnswered(started ? "engine started" : "engine failed — " + (_engineFailure ?? "(no reason recorded)")); + if (!started) { ShowEngineFailure(); @@ -2365,6 +2381,7 @@ private string DiagnosticsText(bool full) : ChromiumImpl.Blocked + " — " + SmackWall.Summary) + "\n" + "own native : " + NativeProbe.Summary + "\n" + "engine imports: " + EngineImports.Summary + "\n" + + "engine stub: " + EngineStub.Summary + "\n" + "trail file : " + Breadcrumbs.Location + "\n" + "trail write: " + Breadcrumbs.Status + "\n" + (full ? "\nefl ladder (ewk_init's own order)\n" + EflSubsystems.Dump() + diff --git a/tools/elfprobe/build.sh b/tools/elfprobe/build.sh index 8df6e54..9f5ee70 100755 --- a/tools/elfprobe/build.sh +++ b/tools/elfprobe/build.sh @@ -1,8 +1,11 @@ #!/usr/bin/env bash -# Builds Overscan5/res/libovprobe.so, the native library issue #17's test loads. +# Builds the two native libraries this repo ships, from the assembly beside it: # -# The result is committed, because CI has no ARM toolchain and this file changes -# roughly never. Rebuild it only if the probe itself changes. +# ovprobe.s -> Overscan5/res/libovprobe.so (issue #17) +# psstub.s -> Overscan6/res/libprivileged-service-client.so (issue #105) +# +# Both results are committed, because CI has no ARM toolchain and these files +# change roughly never. Rebuild only if the source beside them changes. # # The toolchain is one .deb and needs no root — the same trick that supplies the # openssl 1.1 shim in build.sh: @@ -10,7 +13,10 @@ # apt-get download binutils-arm-linux-gnueabihf # dpkg-deb -x binutils-arm-linux-gnueabihf_*.deb "$XTOOL" # -# Point XTOOL at where that was extracted. +# Point XTOOL at where that was extracted. The readelf at the end is the check: +# each must say DYN, ARM, its own SONAME, and no NEEDED and no TEXTREL — a +# dependency would make a refusal to load ambiguous, and a text relocation asks +# the loader for a writable code page on a firmware that refuses far less. set -euo pipefail here="$(cd "$(dirname "$0")" && pwd)" @@ -18,11 +24,16 @@ xtool="${XTOOL:?set XTOOL to the extracted binutils-arm-linux-gnueabihf tree}" bin="$xtool/usr/bin" export LD_LIBRARY_PATH="$xtool/usr/lib/x86_64-linux-gnu${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" -out="$here/../../Overscan5/res/libovprobe.so" -mkdir -p "$(dirname "$out")" - -"$bin/arm-linux-gnueabihf-as" -o "$here/ovprobe.o" "$here/ovprobe.s" -"$bin/arm-linux-gnueabihf-ld" -shared -soname libovprobe.so -o "$out" "$here/ovprobe.o" -rm -f "$here/ovprobe.o" +build() { + local source="$1" soname="$2" out="$3" + mkdir -p "$(dirname "$out")" + "$bin/arm-linux-gnueabihf-as" -o "$here/build.o" "$here/$source" + "$bin/arm-linux-gnueabihf-ld" -shared -soname "$soname" -o "$out" "$here/build.o" + rm -f "$here/build.o" + echo "== $out" + "$bin/arm-linux-gnueabihf-readelf" -hd "$out" | grep -E 'Type:|Machine:|Flags:|SONAME|NEEDED|TEXTREL' || true + "$bin/arm-linux-gnueabihf-readelf" --dyn-syms "$out" | grep -E 'FUNC|OBJECT' || true +} -"$bin/arm-linux-gnueabihf-readelf" -hd "$out" +build ovprobe.s libovprobe.so "$here/../../Overscan5/res/libovprobe.so" +build psstub.s libprivileged-service-client.so "$here/../../Overscan6/res/libprivileged-service-client.so" diff --git a/tools/elfprobe/psstub.s b/tools/elfprobe/psstub.s new file mode 100644 index 0000000..c9095b9 --- /dev/null +++ b/tools/elfprobe/psstub.s @@ -0,0 +1,61 @@ +@ A stand-in for libprivileged-service-client.so, the one library the engine's +@ implementation needs that Samsung's retail firmware will not let an app open +@ (issue #105: open(O_RDONLY) is EPERM, above Smack, on an AU7200 running Tizen +@ 6.0). The build-e1a648d census (docs/INTERNALS.md, *A set that passed the +@ third gate*) read the implementation's own import table and found that this is +@ all it takes from that library: four plain C functions, no data, nothing C++. +@ +@ Loaded RTLD_GLOBAL by absolute path before ewk_init, so that when the loader +@ walks libchromium-impl.so's DT_NEEDED it finds this soname already in the +@ process and never goes to /usr/lib for the file it may not open. See +@ EngineStub.cs for the gate that decides when that happens — never on a set +@ where the engine starts on its own. +@ +@ Each function says "done" and does nothing. PS_Mount / PS_Umount / PS_Mknod +@ are the privileged-service calls the engine would use to set up a sandbox +@ mount it does not need in an app that is only a browser; 0 is success in that +@ API's convention (and in every C convention), and an error here would be +@ reported by the engine as its own failure to start, which is what we have +@ already. PS_ErrorToString gets an empty string, so a caller that logs the +@ error of a call that never failed prints nothing rather than reading garbage. +@ +@ No libc, no NEEDED of any kind, same as libovprobe.s: a dependency would make +@ a refusal to load this ambiguous, and the whole build is one question. + + .arch armv7-a + .text + + .global PS_Mknod + .type PS_Mknod, %function +PS_Mknod: + mov r0, #0 + bx lr + .size PS_Mknod, .-PS_Mknod + + .global PS_Mount + .type PS_Mount, %function +PS_Mount: + mov r0, #0 + bx lr + .size PS_Mount, .-PS_Mount + + .global PS_Umount + .type PS_Umount, %function +PS_Umount: + mov r0, #0 + bx lr + .size PS_Umount, .-PS_Umount + + .global PS_ErrorToString + .type PS_ErrorToString, %function +PS_ErrorToString: + adr r0, ps_empty + bx lr + .size PS_ErrorToString, .-PS_ErrorToString + +@ In .text, PC-relative, on purpose: an `ldr r0, =label` into .rodata needs a text +@ relocation (TEXTREL), which the loader satisfies by making the code page +@ writable for a moment — and a firmware that refuses to map a file of ours +@ executable at all is not one to trust with writable-then-executable. +ps_empty: + .byte 0 diff --git a/tools/engineimports/Program.cs b/tools/engineimports/Program.cs index 39753a3..1083cca 100644 --- a/tools/engineimports/Program.cs +++ b/tools/engineimports/Program.cs @@ -8,6 +8,7 @@ namespace Overscan.Harness /// process because the census's books are static. /// /// census the real shape: one dependency refused + /// underlinked a dependency that only resolves in company /// clean nothing refused, nothing unresolved /// hang a file that never answers /// missing no implementation at all @@ -75,6 +76,23 @@ private static int Main(string[] args) Expect(trail.Contains("engine imports: done — "), "the trail ends on done"); break; + case "underlinked": + // The AU7200's libwgt-manifest-handlers.so.1: a library whose own + // DT_NEEDED does not name the sibling that provides a data symbol + // it imports, so alone and RTLD_LOCAL it is "undefined symbol", + // and under the engine, with the whole list in one scope, it + // loads. The census must not count it as refused or its exports + // as a stub's work. + Expect(trail.Contains("engine imports: held back libunder.so — ") && trail.Contains("undefined symbol: sib_value"), + "the first pass holds the library back and names the symbol"); + Expect(trail.Contains("engine imports: loaded libunder.so on the second pass"), "the second pass loads it"); + Expect(!trail.Contains("refused libunder.so"), "it is never called refused"); + Expect(EngineImports.Refused.Count == 0, "nothing refused, got " + EngineImports.Refused.Count); + Expect(EngineImports.Unresolved.Count == 0, "nothing unresolved, got: " + string.Join(", ", EngineImports.Unresolved)); + Expect(dump.Contains(" loaded: libunder.so (second pass)"), "the dump says which pass loaded it"); + Expect(summary.Contains("0 unresolved") && !summary.Contains("refused"), "the summary is clean: " + summary); + break; + case "clean": Expect(EngineImports.Refused.Count == 0, "nothing refused"); Expect(EngineImports.Unresolved.Count == 0, "nothing unresolved, got: " + string.Join(", ", EngineImports.Unresolved)); diff --git a/tools/engineimports/run.sh b/tools/engineimports/run.sh index f19d4a1..1ca346f 100755 --- a/tools/engineimports/run.sh +++ b/tools/engineimports/run.sh @@ -15,7 +15,12 @@ # (one function, one data object), and then makes libblocked.so unopenable the way # the set does (a permission refusal, not a missing file). The census has to name # exactly those two, say libblocked.so was refused and why, and leave cos and -# strlen out of it. The ELF32 path is exercised on the committed ARM +# strlen out of it. A second shape is a library that only loads in company — one +# that imports a data symbol from a sibling it does not name in its own DT_NEEDED +# — which the census must hold back, load on a second pass once the rest are in, +# and never count as refused or its exports as a stub's work (the AU7200's +# libwgt-manifest-handlers.so.1 put twenty-three C++ names on the first census +# that way). The ELF32 path is exercised on the committed ARM # Overscan5/res/libovprobe.so, which has nothing to import and must say so. set -euo pipefail cd "$(dirname "$0")" @@ -72,6 +77,18 @@ run census "$work/lib/libneedy.so" libblocked.so run background "$work/lib/libneedy.so" libblocked.so chmod 644 "$work/lib/libblocked.so" +# A dependency that only loads in company (the AU7200's libwgt-manifest-handlers +# .so.1): libunder.so imports a data symbol from libsib.so without naming it in +# its own DT_NEEDED, and the implementation lists libunder.so first. Alone it is +# "undefined symbol"; after the rest have loaded it is not. +printf 'int sib_value = 3;\n' > "$work/sib.c" +"$CC" -shared -fPIC -Wl,-soname,libsib.so -o "$work/lib/libsib.so" "$work/sib.c" +printf 'extern int sib_value;\nint under_get(void) { return sib_value; }\n' > "$work/under.c" +"$CC" -shared -fPIC -Wl,-soname,libunder.so -o "$work/lib/libunder.so" "$work/under.c" +printf 'extern int sib_value;\nint under_get(void);\nint needy2_use(void) { return under_get() + sib_value; }\n' > "$work/needy2.c" +"$CC" -shared -fPIC -Wl,-soname,libneedy2.so -Wl,--no-as-needed -L"$work/lib" -o "$work/lib/libneedy2.so" "$work/needy2.c" -lunder -lsib +run underlinked "$work/lib/libneedy2.so" + # Nothing to import at all, on the committed ARM ELF32. run clean ../../Overscan5/res/libovprobe.so