Skip to content

Conversation

wodin
Copy link
Contributor

@wodin wodin commented May 2, 2025

Fixes #2272

I went through the Git history for the HTML app source to find all of the commits where the ensureAbsoluteUrl function was modified, and the related issues/discussions. As far as I can see this PR will not cause any regressions.

I also created a test script to verify that the built-in resolve() method works as expected. The script also shows that there are various cases that the current custom implementation does not handle correctly.

String ensureAbsoluteUrl(String ambiguousUrl, Uri referenceAbsoluteUrl) {
  if (ambiguousUrl.startsWith('//')) {
    ambiguousUrl = '${referenceAbsoluteUrl.scheme}:$ambiguousUrl';
  }
  try {
    Uri.parse(ambiguousUrl).origin;
    return ambiguousUrl;
  } catch (err) {
    // is relative
  }
  var currPathSegments = referenceAbsoluteUrl.path
      .split('/')
      .where((element) => element.trim().isNotEmpty)
      .toList();
  String absoluteUrl;
  if (ambiguousUrl.startsWith('/')) {
    absoluteUrl = '${referenceAbsoluteUrl.origin}$ambiguousUrl';
  } else if (currPathSegments.isEmpty) {
    absoluteUrl = '${referenceAbsoluteUrl.origin}/$ambiguousUrl';
  } else if (ambiguousUrl.split('/').where((e) => e.isNotEmpty).length == 1) {
    absoluteUrl =
        '${referenceAbsoluteUrl.origin}/${currPathSegments.join('/')}/$ambiguousUrl';
  } else {
    absoluteUrl =
        '${referenceAbsoluteUrl.origin}/${currPathSegments.sublist(0, currPathSegments.length - (currPathSegments.last.contains('.') ? 1 : 0)).join('/')}/$ambiguousUrl';
  }
  return Uri.parse(absoluteUrl).toString();
}

void compareApproaches(String ref, String base) {
  var baseUri = Uri.parse(base);
  var custom = ensureAbsoluteUrl(ref, baseUri);
  var builtIn = baseUri.resolve(ref).toString();

  if (custom == builtIn) {
    print('PASS - ${base}, ${ref}');
  } else {
    print('FAIL - ${base}, ${ref} => ${custom}');
  }
}

void main() {
  compareApproaches('test.apk', 'https://www.example.com/download.html');                   // FAIL
  compareApproaches('//www.example.com/test.apk', 'https://www.example.com/download.html'); // PASS
  compareApproaches('//test.apk', 'https://www.example.com/download.html');                 // PASS
  compareApproaches('path//test.apk', 'https://www.example.com/download.html');             // FAIL
  compareApproaches('./test.apk', 'https://www.example.com/download.html');                 // FAIL
  compareApproaches('../test.apk', 'https://www.example.com/x/download.html');              // PASS
  compareApproaches('../test.apk', 'https://www.example.com/download.html');                // PASS
  compareApproaches('../test.apk', 'https://www.example.com/x/README');                     // FAIL
  compareApproaches('../test.apk', 'https://www.example.com/README');                       // PASS
  compareApproaches('../test.apk', 'https://www.example.com/x/dl.dir/');                    // FAIL
  compareApproaches('../test.apk', 'https://www.example.com/dl.dir/');                      // PASS

  print('#848');
  compareApproaches('14.5a6/', 'https://dist.torproject.org/torbrowser/');                  // PASS
  compareApproaches('tor-browser-android-aarch64-14.5a6.apk', 'https://dist.torproject.org/torbrowser/14.5a6/');        // PASS

  print('#989');
  compareApproaches('download/Free42Android.apk', 'https://thomasokken.com/free42/');       // PASS

  print('#1253');
  compareApproaches('2.2.0/', 'https://secure.nic.cz/files/datove_schranky/mobile-datovka/');                   // PASS
  compareApproaches('mobile-datovka-2.2.0-android-arm64.apk', 'https://datovka.nic.cz/mobile-datovka/2.2.0/');  // PASS

  print('#1259');
  compareApproaches('/stable/1.21.0/', 'https://buildbot.libretro.com/stable');                                                 // PASS
  compareApproaches('/stable/1.21.0/', 'https://buildbot.libretro.com/stable/');                                                // PASS
  compareApproaches('/stable/1.21.0/android/', 'https://buildbot.libretro.com/stable/1.21.0/');                                 // PASS
  compareApproaches('/stable/1.21.0/android/RetroArch_aarch64.apk', 'https://buildbot.libretro.com/stable/1.21.0/android/');    // PASS

  print('#1937');
  compareApproaches('com.brave.browser_1.71.118.apk', 'https://apk.example.com');           // PASS
  compareApproaches('com.brave.browser_1.71.118.apk', 'https://apk.example.com/');          // PASS
  compareApproaches('com.brave.browser_1.71.118.apk', 'https://apk.example.com/path/');     // PASS

  print('#1998');
  // Looks like the fix was for links starting with '//host', but Signal/TopoDroid don't currently do that
  compareApproaches('//updates.signal.org/test.apk', 'https://updates.signal.org/android/latest.json');             // PASS
  compareApproaches('https://updates.signal.org/android/Signal-Android-website-prod-universal-release-7.40.2.apk',
                    'https://updates.signal.org/android/latest.json');                                              // PASS
  compareApproaches('topodroid_apk/TopoDroidX-6.3.20-35.apk',
                    'http://marcocorvi.altervista.org/caving/speleoapps/speleoapks/TopoDroidApks.html');            // PASS

  var base = 'http://a/b/c/d;p?q';
  var baseUri = Uri.parse(base);

  // Test cases from RFC 3986
  // https://datatracker.ietf.org/doc/html/rfc3986#section-5.4
  var testCases = [
    // reference URL, expected result
    ['g:h', 'g:h'],                             //  0
    ['g', 'http://a/b/c/g'],                    //  1
    ['./g', 'http://a/b/c/g'],                  //  2
    ['g/', 'http://a/b/c/g/'],                  //  3
    ['/g', 'http://a/g'],                       //  4
    ['//g', 'http://g'],                        //  5
    ['?y', 'http://a/b/c/d;p?y'],               //  6
    ['g?y', 'http://a/b/c/g?y'],                //  7
    ['#s', 'http://a/b/c/d;p?q#s'],             //  8
    ['g#s', 'http://a/b/c/g#s'],                //  9
    ['g?y#s', 'http://a/b/c/g?y#s'],            // 10
    [';x', 'http://a/b/c/;x'],                  // 11
    ['g;x', 'http://a/b/c/g;x'],                // 12
    ['g;x?y#s', 'http://a/b/c/g;x?y#s'],        // 13
    ['', 'http://a/b/c/d;p?q'],                 // 14
    ['.', 'http://a/b/c/'],                     // 15
    ['./', 'http://a/b/c/'],                    // 16
    ['..', 'http://a/b/'],                      // 17
    ['../', 'http://a/b/'],                     // 18
    ['../g', 'http://a/b/g'],                   // 19
    ['../..', 'http://a/'],                     // 20
    ['../../', 'http://a/'],                    // 21
    ['../../g', 'http://a/g'],                  // 22

    ['../../../g', 'http://a/g'],               // 23
    ['../../../../g', 'http://a/g'],            // 24

    ['/./g', 'http://a/g'],                     // 25
    ['/../g', 'http://a/g'],                    // 26
    ['g.', 'http://a/b/c/g.'],                  // 27
    ['.g', 'http://a/b/c/.g'],                  // 28
    ['g..', 'http://a/b/c/g..'],                // 29
    ['..g', 'http://a/b/c/..g'],                // 30

    ['./../g', 'http://a/b/g'],                 // 31
    ['./g/.', 'http://a/b/c/g/'],               // 32
    ['g/./h', 'http://a/b/c/g/h'],              // 33
    ['g/../h', 'http://a/b/c/h'],               // 34
    ['g;x=1/./y', 'http://a/b/c/g;x=1/y'],      // 35
    ['g;x=1/../y', 'http://a/b/c/y'],           // 36

    ['g?y/./x', 'http://a/b/c/g?y/./x'],        // 37
    ['g?y/../x', 'http://a/b/c/g?y/../x'],      // 38
    ['g#s/./x', 'http://a/b/c/g#s/./x'],        // 39
    ['g#s/../x', 'http://a/b/c/g#s/../x'],      // 40

    ['http:g', 'http:g'],                       // 41
  ];

  print('');
  print('Test cases from RFC 3986');
  for (var i = 0; i < testCases.length; i++) {
    var testCase = testCases[i];
    var ref = testCase[0];
    var expected = testCase[1];

    // var result = baseUri.resolve(ref).toString();
    var result = ensureAbsoluteUrl(ref, baseUri);
    if (result == expected) {
      print('PASS - ${i}');
    } else {
      print('FAIL - ${i} - "${base}", "${ref}", "${expected}" != "${result}"');
    }
  }
}

Results

FAIL - https://www.example.com/download.html, test.apk => https://www.example.com/download.html/test.apk
PASS - https://www.example.com/download.html, //www.example.com/test.apk
PASS - https://www.example.com/download.html, //test.apk
FAIL - https://www.example.com/download.html, path//test.apk => https://www.example.com//path//test.apk
FAIL - https://www.example.com/download.html, ./test.apk => https://www.example.com//test.apk
PASS - https://www.example.com/x/download.html, ../test.apk
PASS - https://www.example.com/download.html, ../test.apk
FAIL - https://www.example.com/x/README, ../test.apk => https://www.example.com/x/test.apk
PASS - https://www.example.com/README, ../test.apk
FAIL - https://www.example.com/x/dl.dir/, ../test.apk => https://www.example.com/test.apk
PASS - https://www.example.com/dl.dir/, ../test.apk

#848

PASS - https://dist.torproject.org/torbrowser/, 14.5a6/
PASS - https://dist.torproject.org/torbrowser/14.5a6/, tor-browser-android-aarch64-14.5a6.apk

#989

PASS - https://thomasokken.com/free42/, download/Free42Android.apk

#1253

PASS - https://secure.nic.cz/files/datove_schranky/mobile-datovka/, 2.2.0/
PASS - https://datovka.nic.cz/mobile-datovka/2.2.0/, mobile-datovka-2.2.0-android-arm64.apk

#1259

PASS - https://buildbot.libretro.com/stable, /stable/1.21.0/
PASS - https://buildbot.libretro.com/stable/, /stable/1.21.0/
PASS - https://buildbot.libretro.com/stable/1.21.0/, /stable/1.21.0/android/
PASS - https://buildbot.libretro.com/stable/1.21.0/android/, /stable/1.21.0/android/RetroArch_aarch64.apk

#1937

PASS - https://apk.example.com, com.brave.browser_1.71.118.apk
PASS - https://apk.example.com/, com.brave.browser_1.71.118.apk
PASS - https://apk.example.com/path/, com.brave.browser_1.71.118.apk

#1998

PASS - https://updates.signal.org/android/latest.json, //updates.signal.org/test.apk
PASS - https://updates.signal.org/android/latest.json, https://updates.signal.org/android/Signal-Android-website-prod-universal-release-7.40.2.apk
PASS - http://marcocorvi.altervista.org/caving/speleoapps/speleoapks/TopoDroidApks.html, topodroid_apk/TopoDroidX-6.3.20-35.apk

Test cases from RFC 3986

FAIL - 0 - "http://a/b/c/d;p?q", "g:h", "g:h" != "http://a/b/c/d;p/g:h"
FAIL - 1 - "http://a/b/c/d;p?q", "g", "http://a/b/c/g" != "http://a/b/c/d;p/g"
FAIL - 2 - "http://a/b/c/d;p?q", "./g", "http://a/b/c/g" != "http://a/b/c/d;p/g"
FAIL - 3 - "http://a/b/c/d;p?q", "g/", "http://a/b/c/g/" != "http://a/b/c/d;p/g/"
PASS - 4
PASS - 5
FAIL - 6 - "http://a/b/c/d;p?q", "?y", "http://a/b/c/d;p?y" != "http://a/b/c/d;p/?y"
FAIL - 7 - "http://a/b/c/d;p?q", "g?y", "http://a/b/c/g?y" != "http://a/b/c/d;p/g?y"
FAIL - 8 - "http://a/b/c/d;p?q", "#s", "http://a/b/c/d;p?q#s" != "http://a/b/c/d;p/#s"
FAIL - 9 - "http://a/b/c/d;p?q", "g#s", "http://a/b/c/g#s" != "http://a/b/c/d;p/g#s"
FAIL - 10 - "http://a/b/c/d;p?q", "g?y#s", "http://a/b/c/g?y#s" != "http://a/b/c/d;p/g?y#s"
FAIL - 11 - "http://a/b/c/d;p?q", ";x", "http://a/b/c/;x" != "http://a/b/c/d;p/;x"
FAIL - 12 - "http://a/b/c/d;p?q", "g;x", "http://a/b/c/g;x" != "http://a/b/c/d;p/g;x"
FAIL - 13 - "http://a/b/c/d;p?q", "g;x?y#s", "http://a/b/c/g;x?y#s" != "http://a/b/c/d;p/g;x?y#s"
FAIL - 14 - "http://a/b/c/d;p?q", "", "http://a/b/c/d;p?q" != "http://a/b/c/d;p/"
FAIL - 15 - "http://a/b/c/d;p?q", ".", "http://a/b/c/" != "http://a/b/c/d;p/"
FAIL - 16 - "http://a/b/c/d;p?q", "./", "http://a/b/c/" != "http://a/b/c/d;p/"
FAIL - 17 - "http://a/b/c/d;p?q", "..", "http://a/b/" != "http://a/b/c/"
FAIL - 18 - "http://a/b/c/d;p?q", "../", "http://a/b/" != "http://a/b/c/"
FAIL - 19 - "http://a/b/c/d;p?q", "../g", "http://a/b/g" != "http://a/b/c/g"
FAIL - 20 - "http://a/b/c/d;p?q", "../..", "http://a/" != "http://a/b/"
FAIL - 21 - "http://a/b/c/d;p?q", "../../", "http://a/" != "http://a/b/"
FAIL - 22 - "http://a/b/c/d;p?q", "../../g", "http://a/g" != "http://a/b/g"
PASS - 23
PASS - 24
PASS - 25
PASS - 26
FAIL - 27 - "http://a/b/c/d;p?q", "g.", "http://a/b/c/g." != "http://a/b/c/d;p/g."
FAIL - 28 - "http://a/b/c/d;p?q", ".g", "http://a/b/c/.g" != "http://a/b/c/d;p/.g"
FAIL - 29 - "http://a/b/c/d;p?q", "g..", "http://a/b/c/g.." != "http://a/b/c/d;p/g.."
FAIL - 30 - "http://a/b/c/d;p?q", "..g", "http://a/b/c/..g" != "http://a/b/c/d;p/..g"
FAIL - 31 - "http://a/b/c/d;p?q", "./../g", "http://a/b/g" != "http://a/b/c/g"
FAIL - 32 - "http://a/b/c/d;p?q", "./g/.", "http://a/b/c/g/" != "http://a/b/c/d;p/g/"
FAIL - 33 - "http://a/b/c/d;p?q", "g/./h", "http://a/b/c/g/h" != "http://a/b/c/d;p/g/h"
FAIL - 34 - "http://a/b/c/d;p?q", "g/../h", "http://a/b/c/h" != "http://a/b/c/d;p/h"
FAIL - 35 - "http://a/b/c/d;p?q", "g;x=1/./y", "http://a/b/c/g;x=1/y" != "http://a/b/c/d;p/g;x=1/y"
FAIL - 36 - "http://a/b/c/d;p?q", "g;x=1/../y", "http://a/b/c/y" != "http://a/b/c/d;p/y"
FAIL - 37 - "http://a/b/c/d;p?q", "g?y/./x", "http://a/b/c/g?y/./x" != "http://a/b/c/d;p/g?y/./x"
FAIL - 38 - "http://a/b/c/d;p?q", "g?y/../x", "http://a/b/c/g?y/../x" != "http://a/b/c/d;p/g?y/../x"
FAIL - 39 - "http://a/b/c/d;p?q", "g#s/./x", "http://a/b/c/g#s/./x" != "http://a/b/c/d;p/g#s/./x"
FAIL - 40 - "http://a/b/c/d;p?q", "g#s/../x", "http://a/b/c/g#s/../x" != "http://a/b/c/d;p/g#s/../x"
FAIL - 41 - "http://a/b/c/d;p?q", "http:g", "http:g" != "http://a/b/c/d;p/http:g"

@wodin
Copy link
Contributor Author

wodin commented May 5, 2025

I have finally managed to build a version of Obtainium with my changes applied and run it in an emulator. I can confirm that this PR fixes the problem for me.

@ImranR98 ImranR98 merged commit 20d3c41 into ImranR98:main May 5, 2025
@ImranR98
Copy link
Owner

ImranR98 commented May 5, 2025

Thanks, this is very thorough! I had no idea there was a built in function.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

When HTML source URL ends with /download.html, Obtainium cannot find the APK
2 participants